tsc は緑でした。trpc.user.getProfile.useQuery の戻り値には補完が効き、profile.name も profile.bio も、エディタ上では何ひとつ間違っていませんでした。
それでも、デプロイした Worker は INTERNAL_SERVER_ERROR を返し続けました。
原因は型ではありませんでした。D1 が返す列名が avatar_url で、.output() に渡した Zod スキーマが期待していたのは avatarUrl だった、というだけのことです。型検査はビルド時にコードの内側だけを見ます。データベースの列名は、その外側にあります。
個人開発では、この手の失敗に気づく人が自分しかいません。私がこの一件で学んだのは、tRPC が保証してくれる範囲と、保証してくれない範囲の線引きでした。ここを曖昧にしたまま「型安全だから安心」と考えると、コンパイラが通した設計がそのまま本番で 500 を返します。
以下では、tRPC(TypeScript Remote Procedure Call)と Cloudflare Workers を組み合わせたバックエンドを組み直す手順を追いながら、型が守ってくれる境界がどこで切れるのか を毎回明示していきます。ルーターの実装だけでなく、D1 との列名の対応、KV キャッシュの戻り値の形、レート制限の計数の正確さ——私が個人開発で実際に踏んで、手元で数字を取り直したところまで含めて共有します。
tRPC が守る範囲と、守らない範囲
先に全体像を置いておきます。以降の実装は、この表の「守らない」側を人手で埋めていく作業だと考えていただくと読みやすいはずです。
境界 型の保証 破れたときに起きること
クライアント ↔ ルーター定義 あり (`AppRouter` 型の共有)ビルドが落ちる。本番には出ない
入力値 ↔ Zod スキーマ あり (実行時に検証)`BAD_REQUEST` が返る。想定内の失敗
D1 の列名 ↔ 出力スキーマ なし `.output()` が実行時に落ち、全件 500
KV の戻り値 ↔ 宣言した型 なし (`as T` で素通り)形の違う値が型どおりの顔で返る
環境変数(Secrets)の存在 なし デプロイ後の初回リクエストで気づく
型が効くのは上の 2 行だけです。下の 3 行は、書き手が意識して閉じない限り開きっぱなしになります。
tRPC と Cloudflare Workers の組み合わせが強力な理由
tRPC の核心:型の自動伝播
従来の REST API 開発では、バックエンドとフロントエンドで同じ型定義を別々に管理するのが一般的でした。しかし tRPC では、サーバー側で定義した型が自動的にクライアント側に伝わります。
// サーバー側(Cloudflare Workers)
export const appRouter = router ({
getUserProfile: publicProcedure
. input (z. object ({ userId: z. string (). uuid () }))
. query ( async ({ input , ctx }) => {
// 戻り値の型が自動的にクライアントに伝わる
return {
id: input.userId,
name: 'Masaki' ,
email: 'user@example.com' ,
createdAt: new Date (). toISOString (),
};
}),
});
// AppRouter 型をエクスポートしてクライアントと共有
export type AppRouter = typeof appRouter;
このように定義すると、Rork Max 側のコードでは補完が効き、型エラーが即座に検出されます。
Cloudflare Workers のエッジ実行の優位性
Cloudflare Workers は世界 300 以上のデータセンターで動作するエッジコンピューティング環境です。ユーザーに最も近いデータセンターでコードが実行されるため、従来のサーバーレス関数と比べてコールドスタートがなく、レイテンシが極めて低い点が特徴です。
個人開発者にとっての現実的なメリットとして、無料プランで月 10 万リクエストまで処理でき、従量課金のコストも非常に安価です。Rork Max アプリのバックエンドとして、スタートアップのコストを最小限に抑えながら本番グレードのインフラを利用できます。
環境構築:プロジェクト全体の構成設計
モノレポ構成の採用
型を共有するために、フロントエンド(Rork Max)とバックエンド(Cloudflare Workers)を同一リポジトリで管理するモノレポ構成を推奨します。
my-rork-app/
├── apps/
│ └── mobile/ ← Rork Max プロジェクト
│ ├── app/
│ ├── package.json
│ └── ...
├── packages/
│ └── api/ ← tRPC バックエンド(Cloudflare Workers)
│ ├── src/
│ │ ├── index.ts ← Workers エントリーポイント
│ │ ├── router/ ← tRPC ルーター定義
│ │ ├── middleware/ ← 認証・ログ等のミドルウェア
│ │ └── db/ ← Supabase / D1 連携
│ ├── wrangler.toml
│ └── package.json
├── package.json ← ワークスペースルート
└── pnpm-workspace.yaml
依存パッケージのインストール
# バックエンドパッケージ
cd packages/api
pnpm add @trpc/server hono @hono/trpc-server zod
pnpm add -D wrangler @cloudflare/workers-types typescript
# Rork Max(モバイル)側
cd apps/mobile
pnpm add @trpc/client @trpc/react-query @tanstack/react-query zod
wrangler.toml の設定
# packages/api/wrangler.toml
name = "my-rork-api"
main = "src/index.ts"
compatibility_date = "2026-03-01"
compatibility_flags = [ "nodejs_compat" ]
[ vars ]
ENVIRONMENT = "production"
[[ kv_namespaces ]]
binding = "CACHE"
id = "your-kv-namespace-id"
[[ d1_databases ]]
binding = "DB"
database_name = "my-rork-db"
database_id = "your-database-id"
tRPC ルーターの設計と実装
コアルーターの構築
// packages/api/src/router/index.ts
import { router } from '../trpc' ;
import { authRouter } from './auth' ;
import { userRouter } from './user' ;
import { contentRouter } from './content' ;
export const appRouter = router ({
auth: authRouter,
user: userRouter,
content: contentRouter,
});
export type AppRouter = typeof appRouter;
// packages/api/src/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server' ;
import { z } from 'zod' ;
import type { HonoContext } from './types' ;
interface Context {
env : Env ;
userId ?: string ;
}
const t = initTRPC. context < Context >(). create ({
errorFormatter ({ shape , error }) {
return {
... shape,
data: {
... shape.data,
// クライアントに渡すエラー情報を制御
zodError:
error.cause instanceof z . ZodError
? error.cause. flatten ()
: null ,
},
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
// 認証済みユーザーのみアクセス可能なプロシージャ
export const protectedProcedure = t.procedure. use (({ ctx , next }) => {
if ( ! ctx.userId) {
throw new TRPCError ({
code: 'UNAUTHORIZED' ,
message: '認証が必要です' ,
});
}
return next ({ ctx: { ... ctx, userId: ctx.userId } });
});
ユーザールーターの実装例
// packages/api/src/router/user.ts
import { z } from 'zod' ;
import { router, protectedProcedure, publicProcedure } from '../trpc' ;
import { TRPCError } from '@trpc/server' ;
const UserSchema = z. object ({
id: z. string (). uuid (),
name: z. string (). min ( 1 ). max ( 50 ),
email: z. string (). email (),
bio: z. string (). max ( 200 ). optional (),
avatarUrl: z. string (). url (). optional (),
createdAt: z. string (),
});
export const userRouter = router ({
// 公開エンドポイント:ユーザープロフィール取得
getProfile: publicProcedure
. input (z. object ({ userId: z. string (). uuid () }))
. output (UserSchema)
. query ( async ({ input , ctx }) => {
const result = await ctx.env. DB . prepare (
'SELECT * FROM users WHERE id = ?'
). bind (input.userId). first ();
if ( ! result) {
throw new TRPCError ({
code: 'NOT_FOUND' ,
message: 'ユーザーが見つかりません' ,
});
}
// ⚠️ この行は動きません。理由は直後に説明します
return result as z . infer < typeof UserSchema>;
}),
// 認証必須:自分のプロフィール更新
updateProfile: protectedProcedure
. input (z. object ({
name: z. string (). min ( 1 ). max ( 50 ). optional (),
bio: z. string (). max ( 200 ). optional (),
}))
. mutation ( async ({ input , ctx }) => {
const updates : string [] = [];
const values : unknown [] = [];
if (input.name) {
updates. push ( 'name = ?' );
values. push (input.name);
}
if (input.bio !== undefined ) {
updates. push ( 'bio = ?' );
values. push (input.bio);
}
if (updates. length === 0 ) {
throw new TRPCError ({
code: 'BAD_REQUEST' ,
message: '更新するフィールドがありません' ,
});
}
values. push ( new Date (). toISOString (), ctx.userId);
await ctx.env. DB . prepare (
`UPDATE users SET ${ updates . join ( ', ' ) }, updated_at = ? WHERE id = ?`
). bind ( ... values). run ();
return { success: true };
}),
});
SELECT * と .output() を並べた時点で、このコードは 500 を返す
上の getProfile は型検査を通ります。それでも本番では全件 INTERNAL_SERVER_ERROR になります。理由は 2 つあり、どちらも型の外側で起きています。
1つ目は列名です。 後述するマイグレーションでは列を avatar_url / created_at と定義します。D1 の .first() はその列名のままオブジェクトを返します。一方 UserSchema が要求しているのは avatarUrl / createdAt です。.output() はレスポンスを実行時に検証するため、必須プロパティの createdAt が見つからず、そこで例外になります。as z.infer<typeof UserSchema> は TypeScript に嘘をついているだけで、実行時には何の効果もありません。
2つ目は id の形式です。 UserSchema は z.string().uuid() を要求しています。ところが SQLite の lower(hex(randomblob(16))) が返すのはハイフンなしの 32 桁 16 進文字列です。手元で確かめたところ、生成されるのは次のような値でした。
1d6bd9d5eea00bf5749725b94279a2fe ← 長さ 32・区切り文字なし
RFC 4122 の UUID はハイフンで区切られた 8-4-4-4-12 の 36 文字で、14 文字目にバージョン、17 文字目にバリアントを表す桁が入ります。上の値はどの条件も満たしません。つまり列名を直したとしても、id の検証で今度は別の例外になります。
対処は、DB の行を返す前に1回だけ変換関数を通す ことです。境界を1箇所に集めておくと、後から列を増やしたときに直す場所が1つで済みます。
// packages/api/src/db/mappers.ts
import { z } from 'zod' ;
// DB の生の行(snake_case・id はハイフンなし16進)
const UserRowSchema = z. object ({
id: z. string (). regex ( / ^ [0-9a-f] {32}$ / ),
name: z. string (),
email: z. string (). email (),
bio: z. string (). nullable (),
avatar_url: z. string (). nullable (),
created_at: z. string (),
updated_at: z. string (),
});
// API が外に出す形(camelCase)
export const UserSchema = z. object ({
id: z. string (). regex ( / ^ [0-9a-f] {32}$ / ),
name: z. string (). min ( 1 ). max ( 50 ),
email: z. string (). email (),
bio: z. string (). max ( 200 ). optional (),
avatarUrl: z. string (). url (). optional (),
createdAt: z. string (),
});
export function toUser ( row : unknown ) : z . infer < typeof UserSchema> {
// ここで落ちれば「DB のスキーマが変わった」と即座に分かる
const r = UserRowSchema. parse (row);
return {
id: r.id,
name: r.name,
email: r.email,
bio: r.bio ?? undefined ,
avatarUrl: r.avatar_url ?? undefined ,
createdAt: r.created_at,
};
}
ルーター側は return toUser(result); に差し替えるだけです。as を消したことで、マイグレーションで列名を変えた瞬間に UserRowSchema.parse が失敗し、原因の分かるエラーが出ます。as を残したままだと、失敗するのは検証の1段あとになり、メッセージからは列名の話だと読み取れません。
z.string().uuid() を使い続けたい場合は、DB 側を合わせます。SQLite にはハイフン入りを直接生成する関数がないため、randomblob を切り貼りするか、アプリ側で crypto.randomUUID() を生成して INSERT する方が素直です。私は後者を選びました。id の生成規則をアプリのコードに置いた方が、あとで別のストレージへ移す時に迷わないためです。
Zod を境界に1枚だけ挟むという考え方そのものは、クライアント側の fetch でも同じように効きます。この設計を最小構成で試したい方は、Rork が生成した fetch コードに、Zod の検証境界を1枚だけ挟む も参考になるかもしれません。
Cloudflare Workers エントリーポイントの実装
Hono との統合
Hono は Cloudflare Workers 上で動作する軽量 Web フレームワークで、tRPC との統合が公式にサポートされています。
// packages/api/src/index.ts
import { Hono } from 'hono' ;
import { cors } from 'hono/cors' ;
import { logger } from 'hono/logger' ;
import { trpcServer } from '@hono/trpc-server' ;
import { appRouter } from './router' ;
import { createContext } from './context' ;
// Cloudflare Workers の環境変数型定義
export interface Env {
DB : D1Database ;
CACHE : KVNamespace ;
JWT_SECRET : string ;
ENVIRONMENT : string ;
}
const app = new Hono <{ Bindings : Env }>();
// CORSの設定(Rork Max アプリのドメインを許可)
app. use (
'/trpc/*' ,
cors ({
origin: [
'https://your-rork-app.com' ,
'exp://localhost:8081' , // Expo 開発時
],
allowMethods: [ 'GET' , 'POST' , 'OPTIONS' ],
allowHeaders: [ 'Content-Type' , 'Authorization' ],
credentials: true ,
})
);
app. use ( '/trpc/*' , logger ());
// tRPC ハンドラーのマウント
app. use (
'/trpc/*' ,
trpcServer ({
router: appRouter,
createContext : async ( opts , c ) => createContext (opts, c),
})
);
// ヘルスチェックエンドポイント
app. get ( '/health' , ( c ) => {
return c. json ({
status: 'ok' ,
timestamp: new Date (). toISOString (),
environment: c.env. ENVIRONMENT ,
});
});
export default app;
コンテキスト生成と JWT 認証
// packages/api/src/context.ts
import { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch' ;
import type { Context } from 'hono' ;
import type { Env } from './index' ;
import * as jose from 'jose' ;
export async function createContext (
opts : FetchCreateContextFnOptions ,
c : Context <{ Bindings : Env }>
) {
const authHeader = opts.req.headers. get ( 'Authorization' );
let userId : string | undefined ;
if (authHeader?. startsWith ( 'Bearer ' )) {
const token = authHeader. slice ( 7 );
try {
const secret = new TextEncoder (). encode (c.env. JWT_SECRET );
const { payload } = await jose. jwtVerify (token, secret);
userId = payload.sub as string ;
} catch {
// トークンが無効な場合は userId を undefined のまま
}
}
return {
env: c.env,
userId,
};
}
Rork Max 側のクライアント実装
tRPC クライアントの初期化
// apps/mobile/src/lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query' ;
import { httpBatchLink } from '@trpc/client' ;
import type { AppRouter } from '../../../packages/api/src/router' ;
// AppRouter 型をインポートして完全な型安全を実現
export const trpc = createTRPCReact < AppRouter >();
export function createTRPCClient ( getToken : () => string | null ) {
return {
links: [
httpBatchLink ({
url: process.env. EXPO_PUBLIC_API_URL + '/trpc' ,
headers () {
const token = getToken ();
return token
? { Authorization: `Bearer ${ token }` }
: {};
},
}),
],
};
}
React Query プロバイダーのセットアップ
// apps/mobile/src/providers/TRPCProvider.tsx
import { useState } from 'react' ;
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' ;
import { trpc, createTRPCClient } from '../lib/trpc' ;
import { useAuth } from '../hooks/useAuth' ;
export function TRPCProvider ({ children } : { children : React . ReactNode }) {
const { getToken } = useAuth ();
const [ queryClient ] = useState (
() =>
new QueryClient ({
defaultOptions: {
queries: {
retry: 2 ,
staleTime: 5 * 60 * 1000 , // 5分間キャッシュ
},
},
})
);
const [ trpcClient ] = useState (() =>
trpc. createClient ( createTRPCClient (getToken))
);
return (
< trpc.Provider client = {trpcClient} queryClient = {queryClient} >
< QueryClientProvider client = {queryClient} >
{ children }
</ QueryClientProvider >
</ trpc.Provider >
);
}
コンポーネントでのデータフェッチ
// apps/mobile/src/screens/ProfileScreen.tsx
import { View, Text, ActivityIndicator } from 'react-native' ;
import { trpc } from '../lib/trpc' ;
export function ProfileScreen ({ userId } : { userId : string }) {
// 型が完全に補完される。プロパティ名のミスはコンパイル時に検出
const { data : profile , isLoading , error } = trpc.user.getProfile. useQuery (
{ userId },
{
enabled: !! userId,
}
);
if (isLoading) return < ActivityIndicator />;
if (error) {
// tRPC のエラーコードをそのまま使える
return (
< Text >
{ error . data ?. code === ' NOT_FOUND '
? ' ユーザーが見つかりません '
: ' エラーが発生しました '}
</ Text >
);
}
return (
< View >
{ /* profile の型が AppRouter から自動推論される */ }
< Text >{profile.name} </ Text >
< Text >{profile.email} </ Text >
{ profile . bio && < Text >{ profile . bio }</ Text >}
</ View >
);
}
ミューテーションの場合も同様に型安全です。
// プロフィール更新ミューテーション
const updateProfile = trpc.user.updateProfile. useMutation ({
onSuccess : () => {
// キャッシュを無効化して最新データを再取得
utils.user.getProfile. invalidate ({ userId: currentUserId });
},
onError : ( error ) => {
if (error.data?.zodError) {
// Zod バリデーションエラーを詳細に処理できる
console. error (error.data.zodError.fieldErrors);
}
},
});
// 呼び出し時も型安全
updateProfile. mutate ({ name: '新しい名前' , bio: '自己紹介文' });
本番運用のための高度な設計パターン
Cloudflare D1 を活用したデータ永続化
// packages/api/src/db/migrations/0001_create_users.sql
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY DEFAULT ( lower ( hex ( randomblob ( 16 )))),
name TEXT NOT NULL ,
email TEXT UNIQUE NOT NULL ,
bio TEXT ,
avatar_url TEXT ,
created_at TEXT NOT NULL DEFAULT ( datetime ( 'now' )),
updated_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ))
);
CREATE INDEX idx_users_email ON users (email);
DEFAULT (lower(hex(randomblob(16)))) が返すのはハイフンなしの 32 桁 16 進文字列です。前述のとおり z.string().uuid() とは形式が合いません。UUID 形式で揃えたい場合は DEFAULT を外し、アプリ側で crypto.randomUUID() を生成して INSERT する構成にします。Workers ランタイムでも crypto.randomUUID() はそのまま使えます。
# D1 データベースの作成とマイグレーション
npx wrangler d1 create my-rork-db
npx wrangler d1 migrations apply my-rork-db --local # ローカル開発
npx wrangler d1 migrations apply my-rork-db # 本番環境
KV キャッシュ — .all() の戻り値をそのまま入れない
キャッシュ層は書きやすい反面、型が効かない場所の代表でもあります。次は素朴に書いた版です。
// ⚠️ 3つの問題があります(後述)
export async function withCache < T >(
kv : KVNamespace ,
key : string ,
ttlSeconds : number ,
fetcher : () => Promise < T >
) : Promise < T > {
const cached = await kv. get (key, 'json' );
if (cached !== null ) return cached as T ;
const fresh = await fetcher ();
await kv. put (key, JSON . stringify (fresh), { expirationTtl: ttlSeconds });
return fresh;
}
問題 何が起きるか 直し方
as T で素通しD1 の .all() は配列ではなく { results, success, meta } を返す。呼び出し側は配列のつもりで .map() して落ちる fetcher の中で .results を取り出す
cached !== null で判定値として null を保存した場合と、キーが無い場合を区別できない 存在確認は { type: 'json' } の戻り値ではなくラッパーオブジェクトで持つ
await kv.put(...)書き込み完了までレスポンスを返さない。キャッシュミス時のレイテンシに毎回上乗せされる executionCtx.waitUntil() に逃がす
3点を反映した版が次です。
// packages/api/src/utils/cache.ts
type Envelope < T > = { v : T };
export async function withCache < T >(
kv : KVNamespace ,
key : string ,
ttlSeconds : number ,
fetcher : () => Promise < T >,
waitUntil ?: ( p : Promise < unknown >) => void
) : Promise < T > {
// ラッパーに包むことで「値が null」と「キーが無い」を区別する
const cached = await kv. get < Envelope < T >>(key, 'json' );
if (cached) return cached.v;
const fresh = await fetcher ();
const write = kv. put (key, JSON . stringify ({ v: fresh }), {
expirationTtl: ttlSeconds,
});
// 書き込みを待たずにレスポンスを返す
if (waitUntil) waitUntil (write);
else await write;
return fresh;
}
ルーター側では、.all() の戻り値から results を取り出してからキャッシュに入れます。ここを省くと、キャッシュヒット時とミス時で戻り値の形が揃っているにもかかわらず、どちらも配列ではないという分かりにくい状態になります。
export const contentRouter = router ({
getFeaturedContent: publicProcedure. query ( async ({ ctx }) => {
return withCache (
ctx.env. CACHE ,
'featured-content' ,
300 ,
async () => {
const { results } = await ctx.env. DB . prepare (
'SELECT * FROM content WHERE featured = 1 ORDER BY created_at DESC LIMIT 10'
). all < ContentRow >();
return results. map (toContent); // 列名の変換もここで済ませる
},
ctx.waitUntil
);
}),
});
ctx.waitUntil は、コンテキスト生成時に Hono の c.executionCtx.waitUntil を束ねて渡しておきます。バインドを忘れると Illegal invocation になるため、(p) => c.executionCtx.waitUntil(p) の形で包むのが安全です。
なお KV は書き込みが世界中の PoP に伝播するまで時間差があります。「書いた直後に読む」用途には向きません。キャッシュとしては十分ですが、次のレート制限のような数え間違いが許されない用途では別の仕組みが要ります 。
レート制限 — 「60回/分」と書いたコードが、実際には 30.5 回/分しか通さなかった
レート制限は、私が最も長く間違ったまま運用していた箇所です。まず、よく見かける書き方を置きます。
// ⚠️ 公称値どおりには効きません
export const rateLimitMiddleware = t. middleware ( async ({ ctx , next , path }) => {
const ip = 'client-ip' ; // ← ヘッダーから取得する実装が必要
const key = `rate-limit:${ ip }:${ path }` ;
const limit = 60 ; // 1分あたり60リクエスト
const current = await ctx.env. CACHE . get (key);
const count = current ? parseInt (current) : 0 ;
if (count >= limit) {
throw new TRPCError ({ code: 'TOO_MANY_REQUESTS' , message: '...' });
}
await ctx.env. CACHE . put (key, String (count + 1 ), { expirationTtl: 60 });
return next ();
});
問題は 3 つあります。順に見ていきます。
IP が固定文字列のままです。 'client-ip' はプレースホルダで、書き換えないと全利用者が1つのバケットを共有します。同時に使う人が 10 人いれば、1人あたりの割り当ては 10 分の 1 になります。Cloudflare Workers では request.headers.get('CF-Connecting-IP') で実クライアント IP が取れます。X-Forwarded-For は利用者側から詐称できるため、そちらを信頼してはいけません。
TTL が書き込みのたびにリセットされます。 これが一番厄介でした。カウンタを 1 増やすたびに expirationTtl: 60 を指定し直すため、有効期限は常に「最後の書き込みから 60 秒後」へ後ろにずれます。ウィンドウが区切られないまま伸び続ける、という挙動です。
どのくらいずれるのか、実装をそのまま JavaScript で模して測りました。KV の挙動(TTL 経過で消える/put で期限が更新される)を再現し、10 分間の連続アクセスを流した結果です。
流量 上の実装で通った数 時刻バケット方式で通った数
毎秒 1 リクエスト(公称上限ちょうど) 305 件 = 30.5 回/分 600 件 = 60.0 回/分
毎秒 2 リクエスト(公称上限の 2 倍) 420 件 = 42.0 回/分 600 件 = 60.0 回/分
上限ちょうどの流量、つまり本来 1 件も弾かれてはいけないペースで、実際に通ったのは公称値の約半分でした。さらに厄介なのは、流量を 2 倍に増やすと通過数が 増える (30.5 → 42.0)という点です。上限に張り付くと throw して put を飛ばすため、その間だけ期限が伸びなくなり、かえって早く窓が開きます。「制限をきつくしたはずが緩くなる」方向に振れるので、ログを眺めていても不整合に気づけません。
KV は結果整合です。 書き込みが各 PoP に伝播するまで時間差があるため、複数拠点から同時にアクセスされると、それぞれが古いカウンタを読んで加算します。厳密な上限にはなりません。
修正版では、鍵に時刻バケットを含めて窓を固定します。こうすると put で期限を触っても窓の境界は動きません。
// packages/api/src/middleware/rateLimit.ts
import { TRPCError } from '@trpc/server' ;
import { t } from '../trpc' ;
const WINDOW_SEC = 60 ;
const LIMIT = 60 ;
export const rateLimitMiddleware = t. middleware ( async ({ ctx , next , path }) => {
// 実クライアント IP。CF-Connecting-IP は Cloudflare が付け替えるため詐称できない
const ip = ctx.req.headers. get ( 'CF-Connecting-IP' ) ?? 'unknown' ;
// 鍵に時刻バケットを含める → 窓の境界が put で動かない
const bucket = Math. floor (Date. now () / 1000 / WINDOW_SEC );
const key = `rl:${ ip }:${ path }:${ bucket }` ;
const count = Number ( await ctx.env. CACHE . get (key)) || 0 ;
if (count >= LIMIT ) {
const retryAfter = WINDOW_SEC - Math. floor ((Date. now () / 1000 ) % WINDOW_SEC );
throw new TRPCError ({
code: 'TOO_MANY_REQUESTS' ,
message: `リクエストが多すぎます。${ retryAfter } 秒後に再試行してください。` ,
});
}
// 窓 2 つ分を TTL に取り、境界をまたぐ書き込みで消え残りが出ないようにする
await ctx.env. CACHE . put (key, String (count + 1 ), {
expirationTtl: WINDOW_SEC * 2 ,
});
return next ();
});
ctx.req はコンテキスト生成時に opts.req を持ち回しておきます。ヘッダーだけあれば足りるので、headers を抜き出して渡す形でも構いません。
この修正でも、KV の結果整合という制約は残ります。上限を厳密に守らせたい場合、たとえば有料 API を中継していて超過が課金に直結する場面では、カウンタを1箇所に集約できる仕組みが要ります。Durable Objects なら1オブジェクトが単一スレッドで直列に処理するため、読み書きの競合が起きません。実装の当たりをつけたい方は Rork × Cloudflare Durable Objects で「リアルタイム協調アプリ」のバックエンドを作る実装ガイド で扱っている状態管理の考え方が、ほぼそのまま流用できます。
外部 API の鍵を Worker で中継する構成でのレート制限は、Rork(Expo)生成アプリに OpenAI の鍵を直書きすると抜かれます でも扱っています。守りたいものが「自分のサーバー資源」なのか「従量課金される外部 API」なのかで、許容できる誤差が変わる点は意識しておく価値があります。
よくあるエラーと対処法
CORS エラーが発生する場合
先に整理しておくと、React Native のネイティブ実行では CORS は関係ありません 。CORS はブラウザが Origin ヘッダーを付けてプリフライトを行う仕組みで、ネイティブの fetch はその制約下にないためです。exp://localhost:8081 を origin の配列に並べても、実際には照合されません。
CORS の設定が要るのは、Expo Router の Web 書き出しやブラウザからの動作確認を行う場合です。その用途では http://localhost:8081 のような HTTP オリジンを指定します。逆に言えば、ネイティブだけを対象にしているのに CORS エラーが出ているなら、原因はブラウザ経由の呼び出しか、プリフライトを弾いているルーティング側にあります。
Workers の nodejs_compat フラグが必要な場合
tRPC や jose ライブラリは Node.js の組み込みモジュール(crypto 等)を使用します。wrangler.toml の compatibility_flags に ["nodejs_compat"] を追加することで解決します。
型推論が効かない場合
AppRouter の型エクスポートパスが正しいかを確認してください。モノレポでは tsconfig.json のパスエイリアス設定が重要です。paths に "@api/*": ["../../packages/api/src/*"] のようなエイリアスを定義すると管理しやすくなります。
CI/CD:GitHub Actions でのデプロイ自動化
# .github/workflows/deploy-api.yml
name : Deploy API to Cloudflare Workers
on :
push :
branches : [ main ]
paths :
- 'packages/api/**'
- '.github/workflows/deploy-api.yml'
jobs :
deploy :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : pnpm/action-setup@v2
with :
version : 9
- uses : actions/setup-node@v4
with :
node-version : '20'
cache : 'pnpm'
- name : Install dependencies
run : pnpm install --frozen-lockfile
- name : Run D1 migrations
working-directory : packages/api
run : npx wrangler d1 migrations apply my-rork-db
env :
CLOUDFLARE_API_TOKEN : ${{ secrets.CLOUDFLARE_API_TOKEN }}
- name : Deploy to Cloudflare Workers
working-directory : packages/api
run : npx wrangler deploy
env :
CLOUDFLARE_API_TOKEN : ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID : ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GitHub の Secrets に CLOUDFLARE_API_TOKEN(Cloudflare ダッシュボードで発行)と CLOUDFLARE_ACCOUNT_ID を設定するだけで、main ブランチへのプッシュ時に自動デプロイが走ります。
個人開発者の視点から
この構成を選んで良かったと感じているのは、エラーの出る場所が前倒しになったことです。以前は「アプリがクラッシュしたのでサーバーのログを見に行く」という順序でしたが、今は「型が合わないのでビルドが落ちる」で終わる場面が増えました。原因を探す時間が短くなった実感があります。
一方で、この記事で挙げた 3 つの不具合はいずれもビルドを通り抜けています。列名の不一致も、キャッシュの戻り値の形も、レート制限の数え方も、TypeScript から見れば正しいコードでした。型検査が緑であることは、設計が正しいことの証明にはならない ——このあたりまえの一文を、私は本番の 500 を見てからようやく肌で理解しました。
ですので、導入するなら順番をおすすめします。まずルーターを 1 本と手続きを 2 〜 3 個だけ作り、実機から呼んで戻り値を目で確認する。そこまで済んでから認証、キャッシュ、レート制限と足していく。一気に組むと、どの層で形が崩れたのか切り分けられなくなります。
モノレポの構成そのものに迷いがある場合は、Rork × Turborepo モノレポ設計 に共有パッケージの切り方をまとめてあります。REST で組んだ場合との比較を見たい方は Rork × Hono.js × Cloudflare Workers で本番レベル REST API を構築する と読み比べると、tRPC を選ぶ理由がはっきりするはずです。
ここまでの要点
tRPC と Cloudflare Workers の組み合わせが解決してくれるのは、クライアントとルーター定義のあいだの食い違いです。フィールド名の変更も、戻り値の形の変更も、ビルドの時点で止まります。ここは確かに強力です。
同時に、ここで直した 3 箇所はいずれもその保証の外側にありました。
直した箇所 症状 入れた対策
D1 の列名と出力スキーマ 型検査は緑のまま、本番で全件 500 行を変換する toUser() を境界に1枚置く
withCache の戻り値.all() の D1Result が配列の顔で返るresults を取り出し、書き込みは waitUntil へ
レート制限のカウンタ 公称 60 回/分が実測 30.5 回/分 鍵に時刻バケットを含めて窓を固定する
次の一歩としては、いま動いているバックエンドの .output() を 1 つ選び、DB の列名と突き合わせてみるところから始めてみてください。SELECT * と .output() が同じ手続きの中に並んでいたら、そこは高い確率で同じ問題を抱えています。確認は数分で終わりますし、見つかれば本番の 500 を 1 つ減らせます。
私自身、まだ手探りで組み替えている部分の多い構成です。お読みいただきありがとうございました。