💯

プロンプトからREST APIを作るサービス『Hanabi.REST』の技術構成

2024/05/27に公開
3

Hanabi.REST

AIにHonoJSのバックエンドを書かせて遊ぶ、Hanabi.RESTというサービスを一般公開します。それに際して、この記事では、Hanabiの紹介と簡単に技術スタックを解説していきます。

皆さんは、AIがプロンプトからUIを生成する、V0というサービスをご存じですか?僕はあれを見たときに、ある妄想が膨らみました。 「V0のAPI版があれば、プロンプトからWebアプリケーションを作れるやん!!」と。
当初はハッカソン用の小プロジェクトとして始めましたが、想定以上に面白い結果が得られたため、開発を継続することにしました。技術的な制約、様々な黒魔術による不安定な挙動、LLMの劣化など、数多くの壁を乗り越えながら、約半年をかけてようやくリリースに至りました!!

次のリンクから実際にAIが生成したTwitter風のAPIを試すことが出来ます!

https://hanabi.rest/applications/rhGlHQWdZDp

demo-image

また、会員登録すれば誰でもAPIを生成できるので、ぜひ遊んでみてください。

Hanabi.RESTのここがすごい

ここがすごい!!Hanabi.REST♪ Webバックエンドをサクッと作れるんだぞ♪
Hanabi.RESTはAIがプロンプトからHonoJS製のWebバックエンドを生成してくれるサービスです。実際に動作している様子は、下記のツイートの動画をご覧ください。

https://twitter.com/hanabi_rest/status/1778434927799009633

動画のように、プロンプトを元にAIがAPIの仕様を決めて、SQLを書き、HonoJSで実装を行ってくれます。生成されたAPIは、ブラウザ上でそのまま試すことが出来る上に、ローカル環境にクローンしたり、CF Workersに直接デプロイすることもできます。

また、Playgroundでコードをリアルタイムで編集できたり、外部パッケージを型付きでそのまま使えたり、V0や画像からAPIを生成できたりします。

とにかく、Hanabi.RESTは、速く、面白く、動くをコンセプトに開発しています。現状のLLMの性能では、一撃で実用的なAPIを生成することは(コスト面でも)厳しいので、どちらかというと面白い体験を重視しています。今後は有料のより精度のいいモードも搭載する予定ですが、現時点での実用的な使い方は、APIを作る前のちょっとした壁打などを想定しています。

なぜHanabiを作ったのか

正直、Hanabiを作った理由は、ノリと勢いです。
V0を見た後に、API版もできないかなぁ〜〜と漠然と考えていたところ、昨年12月にGPT4 APIかつ割とシンプルなプロンプトだけでそれっぽいバックエンドを生成することができました。 この時、「お、いけるやん」と感触を得たので開発を始めました。

それから愉快な仲間たちと三人四脚で開発を続け、今に至ります。
まじで、この愉快な仲間たちと、後述するHonoの取り回しの良さ、プレイグラウンド周りの黒魔術など、奇跡的にピースがはまって公開に至っています。

技術スタック

以下は、Hanabiの大まかな技術スタックです。

フロントエンド: NextJS AppRouter, Jotai, Shadcn/UI, Vercel
バックエンド: HonoJS, Drizzle ORM, Cloudflare Workers, D1, R2, Durable Object
認証基盤: Clerk

初期はSupabaseとCloudflareを組み合わせてバックエンドをデプロイしていましたが、現在はCloudflare系で完結しています。<=ここ大事
開発はpnpm workspaceを使ったモノレポで行い、CLI部分のみOSSとして別リポジトリに分けています。デプロイはGithub Actionsを使用しており、mainブランチにマージされたコードが自動でリリースされるように設定しました。

フロントエンド

フロントエンドは、NextJSのAppRouterにShadcn/UIで構築しており、Vercelにデプロイしています。本当はCloudflare Pagesにデプロイしたいのですが、変な所で詰まったら嫌なので、まだ試せていません。

Hanabiは、メインページとPlaygroundページの2つのページで構成されていて、それぞれ別オリジンでホストされています。メインページがアプリケーションの大部分を占めていて、Playgroundページは、API生成画面のコード実行部分を担っています。Playgroundはiframeとしてメインページにきれいに埋め込まれているので、ユーザーからは一体化したように見えるはずです。

Playgroundを別オリジンに切り出した理由は、AIなどが作成したコードを安全に実行するためのセキュアな環境を提供するためです。詳細は後述します。

状態管理

Hanabiのフロントエンドでは、メインページとPlaygroundの両方で状態管理ライブラリにJotaiを採用しています。

特にAPI生成画面では、複雑な状態を扱うため、Jotaiが最適でした。Jotaiは、状態をグラフ構造で表現でき、Recoilのような書き心地ながら、バンドルサイズが小さく、軽量で扱いやすかったです。

さらに、Hanabiではjotai-tanstack-queryパッケージを使って、API呼び出しもJotaiのステートグラフ内で完結させることで、Reactのステートを参照する箇所を最小限にしています。

HanabiにはJotaiが無くては管理できなかったと断言できるほどJotaiを使い倒しました。

バックエンド

バックエンドでは、HonoJSとDrizzleORMを主に使って、Cloudflare Workersにデプロイしています。LLMを使ったアプリケーションでは、クライアントと長時間コネクションを張る必要がありますが、サーバーレス環境かつコストや起動時間を考慮すると、デプロイ先はCF Workersがベストだと思います。

また、データの保存先にD1とR2を採用しています。特にD1を採用した理由は、HanabiがそこまでDBを使わないためSQLiteで十分だと判断したのと、CF系で統一できて開発や維持がしやすいためです。

APIの生成部分には、後述する途切れないストリームを実現するためにDurable Objectを使っています。

技術的なこだわりポイント

途切れないストリーム

LLMを使ったプロジェクトでは、レスポンスの遅延を緩和するために、ストリーミングを利用してLLMの生成結果を段階的に返すことがよくあります。しかし、LLMのAPIから返されるレスポンスは通常、Server-Sent Events (SSE) 形式であるため、ページの再読み込みなどでSSEが途切れると、再接続できなくなるという問題がありました。これは、長時間にわたってAPIの生成を行うHanabiのようなアプリケーションにおいては、致命的な問題です。

この問題を解決するために、Cloudflare Durable Object(DO)を活用して、API生成結果のストリーミングを再接続可能にし、複数のセッションから同時に購読できるようにしました。これにより、ページの再読み込みや接続の切断が発生しても、ストリーミングを継続することができます。

以下は、複数のタブから同時にAPI生成を見守るデモです。

https://youtu.be/L3C0j52j2uE

DOには、様々な面白い性質があり、その一つに「IDが同じインスタンスなら、リクエストを跨いでメモリ空間が共有される」というものがあります。これを活用し、チャットアプリみたいな実装をすることで、各生成を購読できるようにしています。DOのおかげで、すべてサーバーレスかつ維持費も少ない形で実装出来ました。

この技術はかなり汎用的だと思うので、後日詳細な技術記事を公開する予定です。

すごいPlayground

Hanabiには、生成したAPIをその場で実行し試すことができる便利なPlayground機能を搭載しています。Playground上では、不明なコードを安全に実行する必要があるのですが、今回はオリジンを分けたiframeを用意して、その内部でコードを実行する方法を取っています。下の画像のオレンジの部分がPlaygroundの領域です。

upload-demo

これらの構成は、下記の記事を参考にさせて頂きました。この場を借りてお礼を申し上げます!

https://zenn.dev/kohii/articles/6481587ffbfa4d#ユーザーが書いたコードを安全に実行する

また、コードエディター部分にはVSCodeと同じようなUXを提供できるmoncaco-editorを使っています。そして、import文を書いて会部パッケージを読み込むと、vscode-denoのように自動的に型が当たるのですが、Worker上で型ファイルのみ単一ファイルにバンドルしてmonaco-editorに投入しています。このひと工夫でHonoのような.dtsファイルが複数ファイルに分かれたパッケージもいい感じに型が効きます。

型ファイルのバンドルは、次のコードで行っています。

/**
 * esm.sh からモジュールの型定義ファイル (index.d.ts) の URL を取得します。
 * @param module モジュール名
 * @returns index.d.ts の URL。存在しない場合は空文字列。
 */
const getIndexeDtsUrl = async (module: string): Promise<string> => {
  const res = await fetch(`https://esm.sh/${module}`);
  const indexDotDtsUrl = res.headers.get("x-typescript-types");
  if (!indexDotDtsUrl) return "";

  return indexDotDtsUrl;
};

/**
 * esm.sh の URL からモジュール名と拡張子を除いたパスを返します。
 * 例: `https://esm.sh/v135/react-dom@18.2.10/server.d.ts` -> `react-dom/server`
 * @param url esm.sh の URL
 * @returns モジュールパス
 */
const replacePath = (path: string) => {
  return path
    .replace("https://esm.sh/v135/", "")
    .replace(/@\d+\.\d+\.\d+/, "")
    .replace(".d.ts", "");
};

/**
 * esm.sh からモジュールのバンドルされた型定義を取得します。
 * @param module モジュール名
 * @returns バンドルされた型定義。取得できない場合は空文字列。
 */
export const getBundledDts = async (module: string) => {
  const indexDotDtsUrl = await getIndexeDtsUrl(module);
  if (!indexDotDtsUrl) return "";

  let queue = [indexDotDtsUrl];
  const dtsFiles: Record<string, string> = {};
  while (true) {
    if (queue.length === 0) break;
    queue = Array.from(new Set(queue)).filter((m) => !dtsFiles[m]);
    const poped = queue.splice(0, 20);

    const fetched = await Promise.all(poped.map((m) => fetch(m).then((res) => res.text())));
    for (let [i, dts] of fetched.entries()) {
      const dtsUrl = poped[i];
      
      // import 正規表現で依存モジュールを取得
      const imports = Array.from(dts.matchAll(/from ["'](.*)["']/g))
        .map((m) => m[1])
        .filter((m) => m.endsWith(".d.ts"));
      const importUrls = imports.map((m) => new URL(m, dtsUrl).toString());
      for (const moduleUrl of importUrls) queue.push(moduleUrl);
      for (const [i, m] of imports.entries()) dts = dts.replace(m, replacePath(importUrls[i]));
      dtsFiles[dtsUrl] = dts;
    }
  }

  // バンドルされた型定義を生成
  let formatted = Object.entries(dtsFiles)
    .map(([url, dts]) => `declare module "${replacePath(url)}"{${dts}}`)
    .join("\n");
  formatted += `declare module "${module}" {export * from "${replacePath(indexDotDtsUrl)}"}`;

  return formatted;
};

このコードでは、esm.shを使って型定義ファイルを再帰的に取得した上で、各ファイルを declare module で囲みモジュールとして扱います。こうすることで、複雑な静的解析なしで、バンドルされた型定義ファイルを生成できるという寸法です。

今後の開発について

今後何を実装するかは、現在チームメンバーで話し合っている途中ですが、大雑把に次の通りです。

  1. より高い精度の有料モード(PROモード)
  2. 外部APIやパッケージを含んだ生成をできるようにする
  3. CLIとDeployのより密接な連携
  4. テスト作成とPlayground上で実行できるように
  5. Playgroundの改善(コンソールやエラーログの表示など)
  6. 教育コンテンツへの応用

既におもちゃとしては相当面白いものが出来たと自負していますが、次のアップデートで実用的なツールに進化させる予定です。
特に、4と1に重点を置いて開発を進め、6月後半のHonoカンファレンスまでに気合で完成させてお披露目する予定です。また、他にも機能に関するアイデアなどあれば、コメントください!!

最後に

ここまでお読みいただきありがとうございます!ぜひHanabiを使用して、独自のAPIを作成してみてください。完成したAPIについては、Twitterで@hanabi_restをメンションしてツイートしていただけると幸いです。また、HanabiのTwitterアカウントをフォローいただいたり、Discordサーバーに参加していただけるとさらに嬉しいです。

https://x.com/hanabi_rest
https://discord.gg/mkMArD6C
https://www.producthunt.com/posts/hanabi-rest

hanabi.rest

Discussion

もっくま(Mistletoe)もっくま(Mistletoe)

はじめまして!限定公開されているときからウォッチ&応援しています。
(もちろんお答えいただける範囲で構わないのですが)ご質問させていただいてもよろしいでしょうか?

生成されたコードをデプロイするのはCloudflareのAPI経由で行っていらっしゃるのでしょうか?
https://zenn.dev/hanabi_rest/articles/ddd6230a62771e#バックエンド

もっくま(Mistletoe)もっくま(Mistletoe)

コメントありがとうございました!
私も Cloudflare のAPIを利用する機会があり、他に利用されている方がいらっしゃるか気になっておりました。

記事楽しみにしております!