Top View


Author Daiki Urata

ExpoアプリでStreaming fetchを実現するための `expo/fetch` API

2024/12/09

はじめに

こんにちは、普段はWebフロントエンドやモバイルアプリ開発を担当している浦田です。 最近Expo SDKのバージョン52がリリースされました。 色々と新機能や変更があったんですが、自分が待望していたストリーミングに対応した expo/fetch APIがExpo SDK 52から登場しましたので、実際にAIチャットアプリを実装して確かめてみます。

バックエンドをHonoでAIチャットアプリ実装

まずはバックエンドをHonoで、OpenAI APIを呼び出しAIチャットを実現しようと思います。

Expoプロジェクト作成

まずはExpoプロジェクトを作成します。

npm run reset-project で不要なボイラープレートは削除します。

npx create-expo-app@latest expo-streming-with-hono
cd expo-streming-with-hono
npm run reset-project # 不要なボイラープレートを削除

Warning

ここで注意が必要なのは --template blank-typescript などで作成してしまうとストリーミングが動きませんでした。 特にドキュメントに明記されていないようでしたが、おそらくExpo Router上ではないと動かないのではないかと推測します。

Honoプロジェクト作成

次にバックエンドとしてHonoを使用します。

特にモノレポ構成とかにする必要はないので、Expoと別プロジェクトでもそのままExpoのプロジェクト内に作るでもどちらでもOKです。

今回はExpoプロジェクトにbackendというディレクトリでHonoプロジェクトを作ります。

npm create hono@latest backend
cd backend

今回はCloudflare Workers想定でプロジェクトを作成しました。

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

OpenAIのAPIを使用したいので、openai をインストールします。

npm install openai

Honoでバックエンド処理実装

Honoではストリーミングがサポートされているので、簡単に実装できました。

import { Hono } from "hono";
import OpenAI from "openai";
import { streamSSE } from "hono/streaming";

type Bindings = {
  OPENAI_API_KEY: string;
};

const app = new Hono<{ Bindings: Bindings }>();

app.post("/", async (c) => {
  const openai = new OpenAI({
    apiKey: c.env.OPENAI_API_KEY,
  });

  const json = await c.req.json<OpenAI.ChatCompletionMessageParam[]>();

  const chatStream = await openai.chat.completions.create({
    model: "gpt-4o",
    messages: json,
    stream: true,
  });

  return streamSSE(c, async (stream) => {
    for await (const chunk of chatStream) {
      const message = chunk.choices[0]?.delta?.content || "";
      await stream.write(message);
    }
  });
});

export default app;

今回はCloudflare Workers想定でセットアップしたので、.dev.varsを作成してローカルで動かすための環境変数を設定します。

OPENAI_API_KEY=xxxxxxxxxx

npm run dev でサーバーを起動したら、バックエンドの準備OKです。

Expoアプリ側で呼び出す処理の実装

いよいよ本題の expo/fetch APIを使用してストリーミングを扱う処理を書いていきます。

app/index.tsx:

import { useState } from "react";
import { SafeAreaView, ScrollView, Text, TextInput, View } from "react-native";
import { fetch } from "expo/fetch";

const API_URL = "http://localhost:8787"; // Hono側のURL

export default function Index() {
  const [input, setInput] = useState("");
  const [messages, setMessages] = useState([
    {
      role: "system",
      content: "Hello, how can I help you?",
    },
  ]);
  async function sendMessage(newMessages: { role: string; content: string }[]) {
    try {
      setMessages([
        ...newMessages,
        {
          role: "system",
          content: "Typing...",
        },
      ]);
      const res = await fetch(API_URL, {
        method: "POST",
        headers: {
          "Accept": "text/event-stream",
        },
        body: JSON.stringify(newMessages),
      });
      const reader = res.body?.getReader();
      if (!reader) return;
      let decoder = new TextDecoder();
      let content = '';
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        const text = decoder.decode(value, { stream: true });
        content += text;

        setMessages((messages) => [
          ...messages.slice(0, -1),
          {
            role: "system",
            content: content,
          },
        ]);
      }
    } catch (error) {
      console.error(error);
    }
  }

  return <SafeAreaView style={{ height: "100%" }}>
    <View
      style={{
        height: "95%",
        display: "flex",
        flexDirection: "column",
        paddingHorizontal: 8,
      }}
    >
      <ScrollView style={{ flex: 1 }}>
        {messages.map((m, index) => (
          <View key={index} style={{ marginVertical: 8 }}>
            <View>
              <Text style={{ fontWeight: 700 }}>{m.role}</Text>
              <Text>{m.content}</Text>
            </View>
          </View>
        ))}
      </ScrollView>

      <View style={{ marginTop: 8 }}>
        <TextInput
          style={{ backgroundColor: "white", padding: 8 }}
          placeholder="Say something..."
          value={input}
          onChange={(e) =>
            setInput(e.nativeEvent.text)
          }
          onSubmitEditing={(e) => {
            const newMessages = [...messages, { role: "user", content: input }];
            sendMessage(newMessages);
            setInput("");
          }}
          autoFocus={true}
        />
      </View>
    </View>
  </SafeAreaView>
}

UI部分は特に解説しませんが、sendMessage でHonoへのリクエスト処理、ストリーミングのハンドリング、メッセージの状態をセットする処理を書いています。

fetchは標準のものではなく、expo/fetchから忘れずインポートして使いましょう。

API_URLは適宜自分の環境に合わせて変更してください。

結果

あとはHonoサーバーを起動した状態で、Expo側で npm run start してシミュレータで動かしてみると以下のように動きました。

Expo AI Chat App with Hono

バックエンドをNext.jsでAIチャットアプリ実装

なんとVercelの出しているAI SDKでも expo/fetch を使用した実装方法がドキュメントに載っていたので、そちらもやってみたいと思います。

Expoプロジェクト作成

先ほどのHonoと同じように新しくExpoプロジェクトを作成します。

npx create-expo-app@latest expo-streming-with-next
cd expo-streming-with-next
npm run reset-project # 不要なボイラープレートを削除

Next.jsプロジェクトを作成

今回もそのままExpoプロジェクト内にbackendディレクトリの名前で作成します。

npx create-next-app@latest backend
cd backend

Next.js側に必要なパッケージのインストール

ここでAI SDKをインストールします。

npm install @ai-sdk/openai ai

Next.jsでバックエンド処理実装

Route Handlerを用意して、バックエンド処理を実装します。

AI SDKのおかげで記述量が少なくなりました。

app/api/chat/route.ts:

import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = streamText({
    model: openai("gpt-4o"),
    messages,
  });

  return result.toDataStreamResponse();
}

環境変数の設定も忘れずに行います。

.env.local:

OPENAI_API_KEY=xxxxxxxxxx

これでバックエンドの準備ができました。npm run dev でサーバーを起動しておきます。

Expoアプリ側に必要なパッケージをインストール

Expo側にもAI SDKをインストールして使います。

cd .. # プロジェクトルートへ移動
npm install @ai-sdk/react

Expoアプリ側から呼び出す処理を実装

UI側の処理はほぼ公式ドキュメントのままで動きました。URLを変えたぐらいです。

app/index.tsx:

import { useChat } from "@ai-sdk/react";
import { fetch as expoFetch } from "expo/fetch";
import { View, TextInput, ScrollView, Text, SafeAreaView } from "react-native";

const API_URL = "http://localhost:3000";

export default function App() {
  const { messages, error, handleInputChange, input, handleSubmit } = useChat({
    fetch: expoFetch as unknown as typeof globalThis.fetch,
    api: API_URL + "/api/chat",
    onError: (error) => console.error(error, "ERROR"),
  });

  if (error) return <Text>{error.message}</Text>;

  return (
    <SafeAreaView style={{ height: "100%" }}>
      <View
        style={{
          height: "95%",
          display: "flex",
          flexDirection: "column",
          paddingHorizontal: 8,
        }}
      >
        <ScrollView style={{ flex: 1 }}>
          {messages.map((m) => (
            <View key={m.id} style={{ marginVertical: 8 }}>
              <View>
                <Text style={{ fontWeight: 700 }}>{m.role}</Text>
                <Text>{m.content}</Text>
              </View>
            </View>
          ))}
        </ScrollView>

        <View style={{ marginTop: 8 }}>
          <TextInput
            style={{ backgroundColor: "white", padding: 8 }}
            placeholder="Say something..."
            value={input}
            onChange={(e) =>
              handleInputChange({
                ...e,
                target: {
                  ...e.target,
                  value: e.nativeEvent.text,
                },
              } as unknown as React.ChangeEvent<HTMLInputElement>)
            }
            onSubmitEditing={(e) => {
              handleSubmit(e);
              e.preventDefault();
            }}
            autoFocus={true}
          />
        </View>
      </View>
    </SafeAreaView>
  );
}

すごいですね。先ほどメッセージの状態管理やストリーミングのハンドリング処理などは全てAI SDKが提供する useChat に集約されました。

結果

こちらもNext.jsサーバーを起動した状態で、Expo側で npm run start してシミュレータで動かしてみると以下のように動きました。

Expo AI Chat App with Next.js

さいごに

これまではReact Nativeでストリーミングを扱う際は標準のfetch APIで対応されていなかったので、実現したい場合はWebView上でストリーミングfetchを行い、postMessage経由などで結果を受け取る方法や、ネイティブ側(Swift/Kotlin)でfetch処理するなどまわりくどいことをする必要がありました。

expo/fetch が使えるようになったことで、全てReact Nativeの世界で完結できるようになり、今回のようなAIチャットアプリなどが実装しやすくなったと思います。また、VercelのAI SDKも使うとさらに実装が楽になりました。

Expo Router上でしか動かないなど、制約はありそうですが、WinterCG仕様に対応しているとのこともあり、Expoプロジェクトならこちらを採用していくのが今後は良さそうです!

Daiki Urata

Daiki Urata

Twitter X

フロントエンド/モバイルアプリなどを主に開発しています。