create-t3-turbo

背景
- 普段はNext.jsを中心にWebアプリケーションの開発をしている
- 今回、はじめてネイティブアプリ開発に挑戦する
- Reactつながりで多少は勝手が分かりそうなReact Nativeをとりあえず選定した
- Reactに対するNext.js的な感じで、Expoってのを使うといろいろbattery-includeっぽい
- サーバーサイドの型情報がリポジトリとして分離するのはつらい
- モノレポでHono RPCやtRPCなどの仕組みを使いたい
- turborepoとかも使ったことないし、セットアップを自前で調整するのはダルい
-
なんかcreate-t3-turboが想像してるようなことだいたい全部やってくれてそう
- turborepoでtRPCとExpoを使用してくれてる(便利!)
- モノレポでHono RPCやtRPCなどの仕組みを使いたい
セットアップ
GitHubテンプレートのみでpnpm create t3-app@latest
みたいなのができるnpmパッケージは存在しないっぽい。
公式リポジトリから「Use this template」して使う。
ちなみに今まで曖昧な理解で使っていたが、以下のような関係らしい。
-
pnpx
=pnpm dlx
-
pnpm dlx create-*
=pnpm create *
ちゃんと公式ドキュメントを一度くらい読まないとダメね。

npmパッケージは存在しないっぽい
めっちゃ嘘を書いてしまっていた。
npx create-turbo@latest -e https://github.com/t3-oss/create-t3-turbo
すればいけると公式READMEにわかりやすく書いてある。

DB変更
DB含め、APIはedge前提で作られているっぽい。
となるとDB接続はWebSocketベースになるが、ローカルDBに繋げないのは面倒臭い。
ということでNeonを使いつつローカルDBに繋げられるように変更。
pnpm rm @vercel/postgres
pnpm add @neondatabase/serverless ws
pnpm add -D @types/ws
import type { Config } from "drizzle-kit";
if (!process.env.POSTGRES_URL) {
throw new Error("Missing POSTGRES_URL");
}
-
- const nonPoolingUrl = process.env.POSTGRES_URL.replace(":6543", ":5432");
export default {
schema: "./src/schema.ts",
dialect: "postgresql",
- dbCredentials: { url: nonPoolingUrl },
+ dbCredentials: { url: process.env.POSTGRES_URL },
casing: "snake_case",
} satisfies Config;
import { neonConfig, Pool } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-serverless";
import { WebSocket } from "ws";
import * as schema from "./schema";
if (process.env.NODE_ENV === "production") {
neonConfig.webSocketConstructor = WebSocket;
neonConfig.poolQueryViaFetch = true;
} else {
neonConfig.wsProxy = (host) => `${host}:5433/v1`;
neonConfig.useSecureWebSocket = false;
neonConfig.pipelineTLS = false;
neonConfig.pipelineConnect = false;
}
const pool = new Pool({ connectionString: process.env.POSTGRES_URL });
export const db = drizzle({
client: pool,
schema,
casing: "snake_case",
});
services:
postgres:
image: 'postgres:latest'
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- '5432:5432'
pg_proxy:
image: ghcr.io/neondatabase/wsproxy:latest
environment:
APPEND_PORT: 'postgres:5432'
ALLOW_ADDR_REGEX: '.*'
LOG_TRAFFIC: 'true'
ports:
- '5433:80'
depends_on:
- postgres

シミュレーターがちゃんと起動しない
プロジェクトのルートでpnpm dev
してもコケる。
CommandError: No development build (your.bundle.identifier) for this project is installed.
apps/expo
配下でpnpm ios
すると何やらCocoaPodsとかのインストールが始まって起動できるようになった。
上記の操作が一体どこに何をして、どんな意味を持つのかは現時点では不明だが、とりあえず動いてそうなので今は作ることを優先する。
あと、your.bundle.identifier
はどこで設定できるんだろう。まあデプロイ前に調べればいいや。
Expo Goで動かす方法は不明
s
を押すとExpo Goモードにできるっぽいのだが、t3-turboのロゴが表示され続けるだけでアプリは起動しなかった。
こちらも理由は不明だが、後回し。

- Expo Go
- development build
- managed workflow
- bare workflow
などいろいろあるようだが、Expo Goだと限られたモジュールしか使えずproduction向きではないので、t3-turboでは初めからdevelopment buildが必要らしい。
development buildをすると(理解が正確かはわからないが)、自分専用のExpo Go環境みたいなのを都度生成するイメージっぽい。
さらにbare workflowではネイティブコードの入っている/iosフォルダの依存関係なりを自力で解決する必要も出てくるらしい。bare workflowでもEAS buildは使える?らしいのでExpoの抽象化の恩恵は一応あるらしい。
とりあえず、apps/expo
配下でpnpm ios
なりをすると、Expo Goでなくともsimulatorでホットリロードしながら開発できる環境を得られるという理解でいったん終える。

Post作成しようとしたらログインが必要だと怒られた
いったんバイパスに挑戦する。

Expoのディレクトリ構造を理解する
公式ドキュメントを読む前にまずはあたりをつける。
src/app/_layout.tsx
を読むと<Stack />
が現在の表示ページで、それをラップするようなレイアウト定義らしい。いま表示するページはどう決まるんだろうか。
ページの実体ぽいのはsrc/app/index.tsx
っぽい。src/app/post/[id].tsx
みたいなのもあるし、このあたりはNext.jsのようにファイルベースのルーティングが一定あるっぽい。
見た感じ<CeatePost />
コンポーネントが上のエラー出してるフォームっぽい。
{error?.data?.code === "UNAUTHORIZED" && (
<Text className="mt-2 text-destructive">
You need to be logged in to create a post
</Text>
)}
はいはい。
tRPCの対象エンドポイントはpackages/api
のほうに分離されている。いいね。
create: protectedProcedure
.input(CreatePostSchema)
.mutation(({ ctx, input }) => {
return ctx.db.insert(Post).values(input);
}),
問答無用でpublicProcedure
に書き換えてちゃんとDBレコード作成されるか見る。

ちーん。
DB接続でコケた。
POST /api/trpc/post.create?batch=1 500 in 491ms
>>> tRPC Request from expo-react by undefined
[TRPC] post.create took 479ms to execute
>>> tRPC Error on 'post.create' error: client selected an invalid SASL authentication mechanism
at eval (webpack-internal:///(rsc)/../../node_modules/@neondatabase/serverless/index.mjs:1359:74)
... 30 lines matching cause stack trace ...

packages/db/src/client.ts
のコピペミスで指定すべきオプションが抜けてるだけだった。
無事にpost.createが通った。

Expo SDK パッケージの利用
expoにはネイティブ動作のラッパーがいろいろあるらしく、とりあえずexpo-background-fetch
を使ってみようと思った。
/apps/expo
上でExpo SDK パッケージをインストールする。
npx expo install expo-background-fetch
npx expo install expo-task-manager
このときpnpm add expo-*
とはせずにnpx expo install expo-*
とすることが推奨される。
expo内での互換性や依存性の解決をうまい感じにやってくれるらしい。

インストール周りの挙動がよくわからん。pnpm clean
だとかnpx expo install
などを何度かやり直すといきなり起動できるようになったりする。
とりあえず困ったらpnpm clean
でキャッシュ削除してやり直すことにする。
turborepo、xcode、react-native、expoなど登場人物が多く、なにがどこで影響を及ぼし合っているのか把握するのが大変だ。

コミットを巻き戻してもう一度順番にやってみたらnpx expo install --fix
をしたタイミングで動くようになったっぽい。
あと、pnpm ios
= npx expo run:ios
の工程でExpo Development Buildが行われるらしい。
AIに簡単に聞いただけだが、Expo Development BuildはExpo Goの代替として登場したもので、本質的には自分専用のExpo Goを作るようなものらしい。開発ビルドを作成すると、そのアプリには指定したネイティブモジュール(Expo未対応のものも含め)を組み込めるため、Expo Goで動かなかった機能もテスト可能になる。具体的には、ローカルでnpx expo run:ios
等を実行して開発モードのビルドを作る。
一度生成すれば、そのDevクライアントアプリ上でExpo Go同様に開発中のJSコードをホットリロードしながら動作確認できる。JSの修正ごとにアプリを再インストールする必要はなく、あくまで新しいネイティブ依存を追加したときにのみDevクライアント自体を再ビルドすれば良い点は開発効率を損なわない。

expo-background-fetch
利用するのに、以下の指定が必要。
ios: {
bundleIdentifier: "your.bundle.identifier",
supportsTablet: true,
+ infoPlist: {
+ UIBackgroundModes: ["fetch"],
+ },
},
ちなみにしばらく以下のエラーがずっと出て困っていたが、先述のnpx expo install --fix
により動くようになった。
Error: Task 'background-fetch' is not defined. You must define a task using TaskManager.defineTask before registering.
イベントの手動発火は以下の手順に従えばよい。
ただし、今回はExpo Development Buildのため、指定するsimulatorをExpo Goではなくてexpoにする。
ちゃんとタスク登録されて「Unregister BackgroundFecth task」に表示が切り替わっている。
イベントを手動発火するとDevToolsにちゃんとコンソール表示が出た。

auth-proxyのデプロイ
turborepoなのでVercelでプロジェクト作成しようとすると対象appをプルダウンで選択できる。
auth-proxyをrootに指定してデプロイする。
ちなみに、DiscordのDeveloperアカウントは以前に別のアプリで都合よく作成済みだった。
環境変数にはそのプロジェクトのURLを指定する。このとき、auth-proxyのAUTH_REDIRECT_PROXY_URL
には末尾の/r
は付けない(Next.jsプロジェクトの環境変数には/r
を付ける)。
ローカルでも無事にログインできた(表記が「Sign Out」になっている)。

実機にインストールしたい
Apple Developer Programへ入金は終わったものの、Apple側の確認に最大48時間かかるらしく支払いプロセスが完了していない。
そのため、実質的に無料アカウントの状態で実機インストールを試すことができないか試行錯誤した。
この記事に助けられて、ローカルビルドを試すことができた。
npx expo export:embed \
--entry-file='node_modules/expo-router/entry.js' \
--bundle-output='./ios/main.jsbundle' \
--dev=false \
--platform='ios'

新しくExpo SDK パッケージを追加したとき
apps/expo
上でpnpm ios
するのを忘れるとモジュールが読み込めないので、パッケージ追加時は忘れずビルドする。

expo-background-fetchが動作しない
格闘したが動かなかったのでreact-native-background-fetchを利用することにした。
こちらは頻度は少ないものの動いている。
導入手順は下記のブログの手順のままでOKだった。
npx expo install react-native-background-fetch
// 追記部分のみ抜粋
export default ({ config }: ConfigContext): ExpoConfig => ({
ios: {
infoPlist: {
UIBackgroundModes: ["fetch", "processing"],
BGTaskSchedulerPermittedIdentifiers: ["com.transistorsoft.fetch"],
},
},
android: {
permissions: ["RECEIVE_BOOT_COMPLETED", "WAKE_LOCK", "FOREGROUND_SERVICE"],
},
plugins: ["expo-router", "react-native-background-fetch"],
});
export default function Index() {
// set background fetch
useEffect(() => {
const initBackgroundFetch = async () => {
try {
const status = await BackgroundFetch.configure(
{
minimumFetchInterval: 15, // minutes
enableHeadless: true,
stopOnTerminate: false,
startOnBoot: true,
requiredNetworkType: BackgroundFetch.NETWORK_TYPE_ANY,
},
// main task
(taskId) => {
asyncFn
.then(() => {
console.log("[BackgroundFetch] taskId: ", taskId);
BackgroundFetch.finish(taskId);
})
.catch((error) => {
console.error(
"[BackgroundFetch] Failed: ",
error,
);
BackgroundFetch.finish(taskId);
});
},
// timeout fallback
(taskId) => {
console.warn("[BackgroundFetch] TIMEOUT task: ", taskId);
BackgroundFetch.finish(taskId);
},
);
console.log("[BackgroundFetch] configure status: ", status);
} catch (error) {
console.error("[BackgroundFetch] Failed to configure: ", error);
}
};
void initBackgroundFetch();
}, []);
// ...
}
expo-background-fetch
試したこと
- ライブラリをインストール
- app.config.tsのinfoPlist等の追記
- UIBackgroundModes
- BGTaskSchedulerPermittedIdentifiers(expo-task-managerで登録するものと一致させる)
- タスク登録の成功確認
- Instrumentsアプリを用いたsimulatorでの起動確認
できなかったこと
- 実機での動作

TestFlightでの配布
有償アカウントの登録ができたのでTest Flightを試すことにした。
eas build --profile production --platform ios --local
apps/expo配下にbuild-0000000000000.ipa
(0の部分は何らかの数字)という形式でビルド成果物が出力されるので、これをTransporter
アプリにドラッグアンドドロップすればよい。
App Store Connectのページで対象アプリのTestFlightタブを開くとこんな感じで警告が出ているはず。
「管理」を押して進める。
とりあえず当てはまりそうな項目をクリックすると準備完了状態になる。
たぶん準備完了になると準備できたってメールも飛んでくるはず。
あー、この手順の前に「内部テスト」を追加して自分自身をテスターに追加する工程も必要かも。
難しくないので省く。
iPhoneでTestFlightアプリをインストールしてログインすれば、登録したアプリがインストールできるようになっているはず。

react-native-chart-kitが日付軸に対応してない
もしかしたら対応しているのかもしれんがAIも無理と言うし、そんな悩みをredditに投稿している人もいたのでたぶん本当なんだろう。
react-native-echartsのビルドエラーが解消できない
react-native-echartsが雰囲気良さそうだったので使おうと思ったが、hermesのエラーが出て無理だった。
Metroに対応していないのかも?
victory-nativeを使う
Cursorのcomposerに投げたら素のReactと混同してひどいサジェストが出まくったので公式ドキュメントを最初にぶん投げて作らせるといいかもしれない。
自分は面倒になってドキュメントを見て自力で実装した。
AIが別のものと勘違いするモードに入ると人間の方がまだ圧倒的に早い。

Expo Batteryで取得できるバッテリー残量が不正確
なんでだ〜、と思ってたらこれはExpo特有の問題ではない模様。
Battery.getBatteryLevelAsync()は内部的にiOSのUIDevice.currentDevice.batteryLevelを呼び出しているらしいが、このネイティブのAPI自体がiOS17からは5%刻みでしか取得できないらしい。
上記のページで、これはデバイスの個別特定につながりうる情報なのでプライバシーの観点で丸められているというようなことが書いてある。

まだ試せていないこと
- 自前のネイティブコードの組み込み
- Push通知
- Expoの仕組みを利用するパターン
- FCMを使用するパターン(こっちのほうが実装は手間だが安い?)

AndroidのEmulatorリセット
Android Studioを開いて[More Actions] -> [Virtual Device Manager]

Simulatorが開いた状態で画面上部のメニューバーから[Device] -> [Erase All Content and Settings...]するとリセットされるっぽい。
画面サイズを元に戻したいときは、[Window] -> [Point Accurate]

iOSエミュレーターの切り替え方
pnpm dev
してからShift + i
で選択画面になるけどExpo Goで立ち上がっちゃったりする。
run:ios
をするといけるはず。

久しぶりにビルドしてエラーになる場合
iOSシミュレーターがないケースに注意
[RUN_FASTLANE] Ineligible destinations for the "app" scheme:
[RUN_FASTLANE] { platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device, error:iOS 18.5 is not installed. To use with Xcode, first download and install the platform }
[RUN_FASTLANE] Exit status: 70
XCodeを立ち上げて設定のComponentsからiOSシミュレーターをインストールすること。
ログで怒られているバージョンがないはず。