Expo × OpenAI APIでAI機能を実装する実践チュートリアル【個人開発】
この記事でわかること
- ExpoアプリからOpenAI APIを呼ぶ最小構成
- APIキーをクライアントに置かないサーバーサイドProxy(Cloudflare Workers)の構成
- TypeScriptで型安全にレスポンスを扱う実装例
- ストリーミング応答をリアルタイム表示する方法
個人開発のモバイルアプリにチャットや要約・翻訳などのAI機能を追加したいとき、最初の壁は「APIキーをどこに置くか」です。本記事ではセキュリティと開発スピードを両立する構成を、動くコードつきで解説します。
全体アーキテクチャ
[Expo アプリ (React Native)]
↓ HTTPS
[Proxy サーバー (Cloudflare Workers)]
↓ HTTPS + Authorization
[OpenAI API]
クライアント(アプリ)はProxyにリクエストを送るだけ。OpenAIのAPIキーはサーバー側の環境変数にだけ置くため、アプリをリバースエンジニアリングされてもキーは漏れません。
Step 1 — Expoプロジェクトのセットアップ
npx create-expo-app ai-chat-app --template blank-typescript
cd ai-chat-app
npx expo install expo-constants
.env.local にProxyのURLだけを記載します(キーは絶対に書かない)。
EXPO_PUBLIC_PROXY_URL=https://your-proxy.your-account.workers.dev
EXPO_PUBLIC_ プレフィックスが付いた変数はExpo SDKが自動的にクライアントバンドルに埋め込みます。URLは公開されても問題ない情報なので問題ありません。
Step 2 — Cloudflare WorkersでAPIキーProxyを作る
Cloudflare Workers は無料枠(1日10万リクエスト)があり、個人開発に最適です。
npm create cloudflare@latest ai-proxy -- --template hello-world-typescript
cd ai-proxy
src/index.ts を以下のように実装します。
export interface Env {
OPENAI_API_KEY: string;
ALLOWED_ORIGIN: string; // 例: "exp://192.168.x.x" や "https://yourdomain.com"
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// CORS プリフライト対応
if (request.method === "OPTIONS") {
return corsResponse(new Response(null, { status: 204 }), env);
}
if (request.method !== "POST") {
return corsResponse(new Response("Method Not Allowed", { status: 405 }), env);
}
const body = await request.json<{ messages: { role: string; content: string }[] }>();
const openaiRes = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages: body.messages,
stream: true, // ストリーミング有効
}),
});
// ストリームをそのままクライアントへ転送
return corsResponse(
new Response(openaiRes.body, {
status: openaiRes.status,
headers: { "Content-Type": "text/event-stream" },
}),
env
);
},
};
function corsResponse(res: Response, env: Env): Response {
const headers = new Headers(res.headers);
headers.set("Access-Control-Allow-Origin", env.ALLOWED_ORIGIN);
headers.set("Access-Control-Allow-Headers", "Content-Type");
return new Response(res.body, { status: res.status, headers });
}
APIキーをWorkerのSecret変数に登録します。
wrangler secret put OPENAI_API_KEY
wrangler secret put ALLOWED_ORIGIN
wrangler deploy
注意: 本番運用では
Authorizationヘッダーや独自トークンでProxy自体も保護することを推奨します。
Step 3 — Expoアプリ側の実装
型定義
// types/chat.ts
export type Role = "user" | "assistant" | "system";
export interface Message {
id: string;
role: Role;
content: string;
}
ストリーミング対応のカスタムフック
// hooks/useChat.ts
import { useState, useCallback } from "react";
import { Message } from "../types/chat";
const PROXY_URL = process.env.EXPO_PUBLIC_PROXY_URL!;
export function useChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const sendMessage = useCallback(async (userText: string) => {
const userMsg: Message = {
id: Date.now().toString(),
role: "user",
content: userText,
};
const history = [...messages, userMsg];
setMessages(history);
setIsLoading(true);
// アシスタントの返答プレースホルダ
const assistantId = (Date.now() + 1).toString();
setMessages([...history, { id: assistantId, role: "assistant", content: "" }]);
try {
const res = await fetch(PROXY_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: history.map(({ role, content }) => ({ role, content })),
}),
});
if (!res.ok) throw new Error(`Proxy error: ${res.status}`);
if (!res.body) throw new Error("No response body");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let accumulated = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// SSE の "data: {...}" 行をパース
const lines = chunk.split("\n").filter((l) => l.startsWith("data: "));
for (const line of lines) {
const data = line.slice(6);
if (data === "[DONE]") break;
try {
const json = JSON.parse(data);
const delta: string = json.choices[0]?.delta?.content ?? "";
accumulated += delta;
// リアルタイムでUIを更新
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: accumulated } : m
)
);
} catch {
// パースエラーは無視
}
}
}
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
}
}, [messages]);
return { messages, isLoading, sendMessage };
}
チャット画面コンポーネント
// app/index.tsx
import { useState } from "react";
import {
FlatList, KeyboardAvoidingView, Platform,
StyleSheet, Text, TextInput, TouchableOpacity, View,
} from "react-native";
import { useChat } from "../hooks/useChat";
import { Message } from "../types/chat";
export default function ChatScreen() {
const { messages, isLoading, sendMessage } = useChat();
const [input, setInput] = useState("");
const handleSend = () => {
if (!input.trim() || isLoading) return;
sendMessage(input.trim());
setInput("");
};
const renderItem = ({ item }: { item: Message }) => (
<View style={[styles.bubble, item.role === "user" ? styles.user : styles.assistant]}>
<Text style={styles.text}>{item.content}</Text>
</View>
);
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<FlatList
data={messages}
keyExtractor={(m) => m.id}
renderItem={renderItem}
contentContainerStyle={{ padding: 12 }}
/>
<View style={styles.inputRow}>
<TextInput
style={styles.input}
value={input}
onChangeText={setInput}
placeholder="メッセージを入力..."
editable={!isLoading}
/>
<TouchableOpacity style={styles.sendBtn} onPress={handleSend} disabled={isLoading}>
<Text style={{ color: "#fff" }}>{isLoading ? "…" : "送信"}</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#f5f5f5" },
bubble: { marginVertical: 4, padding: 10, borderRadius: 12, maxWidth: "80%" },
user: { alignSelf: "flex-end", backgroundColor: "#0a7ea4" },
assistant: { alignSelf: "flex-start", backgroundColor: "#fff" },
text: { color: "#111" },
inputRow: { flexDirection: "row", padding: 8, backgroundColor: "#fff" },
input: { flex: 1, borderWidth: 1, borderColor: "#ccc", borderRadius: 8, paddingHorizontal: 10 },
sendBtn: { marginLeft: 8, backgroundColor: "#0a7ea4", borderRadius: 8, paddingHorizontal: 16, justifyContent: "center" },
});
Step 4 — 動作確認
npx expo start
iOSシミュレータ・Androidエミュレータ・実機どれでもOKです。ProxyのURLが正しく設定されていれば、入力したメッセージに対してAIの返答がストリーミングでリアルタイム表示されます。
よくある落とし穴と対処法
| 問題 | 原因 | 対処 |
|---|---|---|
Network request failed | CORSの設定ミス | WorkerのALLOWED_ORIGINとExpoのオリジンを一致させる |
APIキーが undefined | .env.local の変数名ミス | EXPO_PUBLIC_ プレフィックスを確認 |
| ストリームが途中で止まる | Proxyのタイムアウト | Workers の fetch は最大30秒。長い応答は注意 |
| Androidでストリーム未対応 | Hermes の ReadableStream 制限 | Expo SDK 50以降 + expo-fetch ポリフィルで対応可能 |
セキュリティをさらに強化するには
- Proxy独自の認証トークン: アプリ起動時にサーバーが発行したJWTをリクエストヘッダーに付ける
- レートリミット: Cloudflare Workers KVで1ユーザーあたりのリクエスト数を制限
- プロンプトインジェクション対策: システムプロンプトをサーバー側で固定し、ユーザー入力の役割を
userに限定
本格的なサービス展開を考えるなら、VPSやXserverなどでNode.js/Pythonのプロキシサーバーを立てるのも選択肢です。スケールしやすいインフラとして {{A8:xserverVps}} も検討してみてください。
まとめ
| ポイント | 内容 |
|---|---|
| APIキーの扱い | サーバーサイド(Cloudflare Workers)の環境変数のみに保存 |
| クライアントへの露出 | ProxyのURLのみ(EXPO_PUBLIC_PROXY_URL) |
| ストリーミング | SSEをReact Nativeのfetch ReadableStreamで受け取りリアルタイム表示 |
| 型安全性 | Message型・Role型でAPIレスポンスを厳密に管理 |
Expo × OpenAI APIの組み合わせは、個人開発でAI機能を素早く試すのに非常に向いています。まずはCloudflare Workersの無料枠でProxyを立ち上げ、最小構成から動かしてみてください。動いたら認証やレートリミットを段階的に足していくのが現実的なアプローチです。