← ブログ

リアルタイムを hooks に:React、Vue、Nuxt のためのパッケージ

· Hibira

pusher-js を React や Vue につなぐとき、どのチームも同じ useEffect を書き、同じ落とし穴にはまります。私たちはそれを 3 つのパッケージにまとめ、AI の回答をすべてのタブ、すべてのメンバーに届けられるようにしました。

pusher-js を React につなぐとき、たいていこう書き始めます。

useEffect(() => {
  const channel = pusher.subscribe('orders');
  channel.bind('order.created', (order) => setOrders((list) => [order, ...list]));
  return () => pusher.unsubscribe('orders');
}, []);

6 行、特に問題はなさそうです。ところがプロジェクトが育つと、注文一覧と右上の通知ベルの両方が orders を聞くようになります。ページを切り替えると一覧はアンマウントされ、ベルは残り、そしてベルにはもう通知が届きません。開発モードでは React の StrictMode がコンポーネントを 2 回マウントし、接続も 2 本開きます。Next.js に移ると、サーバーレンダリング中にも new Pusher() が走り、誰も使わない WebSocket をサーバー上で開いてしまいます。

こうしたコードを私たちは多くのプロジェクトで見てきました。書き方はほぼ同じで、ハマる落とし穴も同じです。そこで、これを 3 つのパッケージにまとめました。@hibira-io/react、@hibira-io/vue、@hibira-io/nuxt です。この記事では、何をどう解決したのか、そしてついでに作ったもうひとつの機能、AI の回答を書かれていくそばからすべてのタブ、すべてのチームメンバーに届ける仕組みについて紹介します。

落とし穴その 1:2 つのコンポーネント、1 つのチャンネル

pusher-js の subscribe() は同じチャンネル名を一度しか購読しないので、2 つのコンポーネントは同じチャンネルオブジェクトを受け取ります。問題は unsubscribe() です。ほかに誰が使っているかに関係なく、チャンネルごと購読を解除してしまいます。

注文一覧通知ベルpusher-jssubscribe('orders')subscribe('orders')同じチャンネルを受け取るunsubscribe('orders')一覧がアンマウントチャンネルごと解除され、ベルにはもう通知が届かない
2 つのコンポーネントが orders チャンネルを共有し、片方がアンマウントされると

私たちの解決策は、チャンネルごとのカウントです。使うコンポーネントがマウントされるたびに 1 増やし、アンマウントで 1 減らし、0 になったときだけ本当に購読を解除します。useChannel('orders') や useEvent('orders', 'order.created', handler) と書くだけで、どれが先にマウントされ、どれが先に消えるかを気にする必要はありません。

マウント

注文一覧useEvent('orders')
カウント 1
チャンネル orders購読
カウント 2
通知ベルuseEvent('orders')

アンマウント

注文一覧アンマウント
カウント 1
チャンネル orders購読を維持
カウント 0
通知ベル消えてから解除
カウントで本当に解除するタイミングを決める

useEvent は最新のハンドラーも覚えています。コンポーネントは再レンダリングのたびに新しい関数を作ります。毎回イベントを付け直すと、忙しいチャンネルでは一瞬の取りこぼしが生まれます。私たちはイベントを一度だけバインドし、イベントが届いたときに最新のハンドラーを呼びます。

落とし穴その 2:サーバーレンダリングと StrictMode

Next.js、Remix、Nuxt はまずサーバーでページをレンダリングします。コンポーネント本体や useMemo の中で接続を作ると、サーバーでも接続してしまいます。誰も使わない WebSocket が 1 本増え、接続数も 1 つ多く数えられます。

React 18 以降の StrictMode は、片付け忘れの副作用を見つけるために、開発モードでコンポーネントをマウント、アンマウント、再マウントします。手書きの接続コードは、ここで 2 本目の接続を残しがちです。

サーバーレンダリング接続しない、hooks は空の値を返す
HTML
ブラウザの hydrationサーバーと同じ表示
マウント後
接続ws.hibira.io
接続はブラウザで、マウント後にだけ作る

HibiraProvider は useEffect の中でだけ接続し、アンマウントで切断します。StrictMode の 2 回目のマウントでは、きれいな新しい接続が使われます。接続が開くまで usePresence は空のメンバー一覧を、useConnectionState は initialized を返します。サーバーがレンダリングした結果と同じなので、hydration の不一致の警告は出ません。Vue 版も同じ考え方で、プラグインはブラウザで初めて使われたときに接続し、サーバーでは何もしません。

もうひとつ:AI の回答を、質問したタブだけでなく

この 2 年で多くのプロダクトが AI アシスタントを追加しました。よくある実装は Server-Sent Events です。ブラウザがリクエストを送り、バックエンドがモデルの出力を同じレスポンスに少しずつ書き戻します。

一人がひとつのタブで使うなら、これで十分です。でもプロダクトが共同作業の場になると、問題が出てきます。Alice が共有の仕様書の中で AI に質問しても、隣に座る Bob には回答が書かれていく様子が見えません。Alice の側で書き終わって保存され、Bob がリロードして、ようやく見えます。Alice が自分で開いているもうひとつのタブにも届きません。スマートフォンの通信が一瞬途切れると SSE のレスポンスも切れ、回答は半分で止まります。

Server-Sent Events

モデル
ストリーム
バックエンド
同じ HTTP レスポンス
質問したタブ

チャンネルに送る

モデル
ストリーム
バックエンドstreamToChannel()
trigger()
Hibiraファンアウト
WebSocket
すべてのタブ、すべてのメンバー
SSE は質問したリクエストにだけ返す。チャンネルは購読している全員に届ける

そこでパッケージには、バックエンド用の streamToChannel() とブラウザ用の useStream() を入れました。バックエンドはモデルのストリームを streamToChannel() に渡すだけで、テキストがチャンネルに配信されます。そのチャンネルを購読しているコンポーネントは、どれも useStream() で伸びていくテキストを受け取れます。

同じ AI の回答が書かれていく様子を 2 つのブラウザで同時に見ている:左が質問した Alice、右が Bob
同じ AI の回答が書かれていく様子を 2 つのブラウザで同時に見ている:左が質問した Alice、右が Bob

ローカルでのテスト中に撮ったスクリーンショットです。Alice が「AI に聞く」を押すと、回答が Alice と Bob の画面に同時に現れます。Bob の側では何もする必要がありません。

1 トークンごとに 1 メッセージ、ではない

モデルは 1 秒に数十トークンを書きます。トークンごとにメッセージを配信すると、少し長い回答で 1,000 件になります。メッセージの枠はすぐに尽き、受け取る側のブラウザも 1,000 回再レンダリングすることになります。

streamToChannel() はトークンをいったん集め、100 ミリ秒ごとにまとめて配信します。モデルがどれだけ速くても、チャンネルに届くのは 1 秒に約 10 件で、画面上のテキストは途切れずに流れて見えます。

トークンごとに 1 件1,000 件streamToChannel()、100 ミリ秒ごとに 1 件203 件
1,000 トークンを毎秒 50 トークンで生成したときの配信メッセージ数(実測)

モデルが速いほど差は大きくなります。同じ 1,000 トークンでも、毎秒 100 トークンなら 103 件で済みます。各メッセージは 3,500 バイト以内に収まり、Free プランの 1 件 4 KB の上限を下回ります。長いテキストを分けるときも、漢字や絵文字を途中で切ることはありません。

順番、切断、途中から来た人

各メッセージには連番が付いていて、useStream() はその順にテキストをつなぎます。順番が入れ替わって届いても、足りない 1 件を待ってからつなぎます。

最後のメッセージには「完了」の印に加えて、1 件に収まる限り回答の全文を載せます。これは途中から来た人のためです。Bob が回答の途中でページを開くと、それより前のテキストは見られませんが、最後の 1 件が届いた瞬間に画面が完全な回答に置き換わります。通信が一瞬切れた人も同じです。

バックエンドHibiraAliceBob0〜5 件目前半のテキスト0〜5 件目Bob がここでページを開いて購読6〜9 件目6〜9 件目6〜9 件目後半だけ見える10 件目:完了 + 全文完了全文Bob の画面がそろう
回答の途中で Bob が加わる

回答が長すぎて最後の 1 件に全文が入らない場合、途中から来た人には incomplete: true が見えます。そのときは API を一度呼んで、保存済みの回答を取得してください。モデルがエラーになったり中断されたりしたときは購読者に error が届くので、「生成中」のまま止まることはありません。

コードではこうなる

React

import { HibiraProvider, useEvent, usePresence, useStream } from '@hibira-io/react';

export function App() {
  return (
    <HibiraProvider appKey="あなたの app key" options={{ channelAuthorization: { endpoint: '/api/hibira/auth', transport: 'ajax' } }}>
      <Room id="spec-42" />
    </HibiraProvider>
  );
}

function Room({ id }) {
  const { members } = usePresence(`presence-room-${id}`);
  const { text, done } = useStream(`private-room-${id}-ai`);
  useEvent(`private-room-${id}`, 'comment.created', (comment) => addComment(comment));
  return (
    <>
      <Avatars members={members} />
      <Answer text={text} loading={!done} />
    </>
  );
}

バックエンド(Node.js、フレームワークは問いません)は、モデルのストリームをそのまま streamToChannel() に渡します。Anthropic、OpenAI、Gemini、Vercel AI SDK のストリーム形式に対応しています。

import Pusher from 'pusher';
import { streamToChannel } from '@hibira-io/react/server';

const pusher = new Pusher({ appId, key, secret, host: 'ws.hibira.io', useTLS: true });
const stream = anthropic.messages.stream({ model, max_tokens: 1024, messages });
await streamToChannel(pusher, `private-room-${id}-ai`, stream, { meta: { asker: user.name } });

Vue

Vue 3 と Vue 2.7 で動きます。チャンネル名には ref や getter も渡せるので、ルートのパラメーターが変われば購読も自動で切り替わります。

// main.js
createApp(App)
  .use(createHibira({ appKey: import.meta.env.VITE_HIBIRA_KEY, options: { channelAuthorization: { endpoint: '/api/hibira/auth', transport: 'ajax' } } }))
  .mount('#app');
<script setup>
import { usePresence, useStream } from '@hibira-io/vue';

const props = defineProps({ id: String });
const { members, count } = usePresence(() => `presence-room-${props.id}`);
const { text, done } = useStream(() => `private-room-${props.id}-ai`);
</script>

Nuxt

Nuxt 3 と Nuxt 4 ではモジュール設定を 1 行足すだけです。composables は自動インポートされ、サーバールートでも useHibiraServer() と streamToChannel() がそのまま使えます。

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@hibira-io/nuxt'],
});
// server/api/ask.post.ts
export default defineEventHandler(async (event) => {
  const { roomId, question } = await readBody(event);
  const stream = anthropic.messages.stream({ model, max_tokens: 1024, messages: [{ role: 'user', content: question }] });
  event.waitUntil(streamToChannel(useHibiraServer(event), `private-room-${roomId}-ai`, stream));
  return { ok: true };
});

Nuxt 2 のプロジェクトも忘れていません。Nuxt 2.16 以降は Vue 2.7 で動くので、@hibira-io/nuxt/nuxt2 モジュールを使ってください。

どうテストしているか

3 つのパッケージはどれも小さく、pusher-js 以外の依存はありません。ただ、さまざまな環境で動くので、テストの範囲はコードそのものよりずっと広くなっています。

  • React 17、18、19 と Vue 3.5、Vue 2.7 で、それぞれユニットテストを実行。
  • エンドツーエンドテスト:2 つのクライアントを本物のサーバーにつなぎ、presence の参加と退出、クライアントイベント、200 トークンのストリームが両側で欠けずに組み立てられることを確認。
  • Nuxt 4.6、3.21、2.18 でそれぞれ小さなアプリを作り、Chromium で 2 人のユーザーとして開いて、接続、presence、サーバーイベント、ストリームのテキストを確認。コンソールのエラーも hydration の警告もなし。

はじめかた

npm install @hibira-io/react pusher-js   # React
npm install @hibira-io/vue pusher-js     # Vue 3 / Vue 2.7
npx nuxi module add @hibira-io/nuxt      # Nuxt 3 / 4

始める前に、Hibira のコンソールでいくつか設定しておきます。

  • アプリを作成し、認証情報を控える。 App ID と key はアプリの「概要」ページでいつでも確認できます。secret は作成時に一度だけ表示されるので、その場でバックエンドの環境変数に保存してください(Nuxt では NUXT_PUBLIC_HIBIRA_KEY、NUXT_HIBIRA_APP_ID、NUXT_HIBIRA_SECRET)。key は公開してよく、secret はサーバーにだけ置きます。
  • 許可するオリジンを追加する。 Free プランでは * が使えません。サイトのアドレス(開発中なら例えば http://localhost:3000)を「許可するオリジン」に追加しないと、ブラウザは接続できません。
  • 認可ルートを用意する。 プライベートチャンネルと presence チャンネルには、ログイン中のユーザーを確認して購読に署名するバックエンドの /api/hibira/auth が必要です。3 つの README にそのまま使える例があります。
  • 1 メッセージ 4 KB。 Free プランではイベント 1 件あたり 4 KB までです。streamToChannel() は自動でこの範囲に収めますが、自分で配信するイベントのサイズには気をつけてください。

上の Alice と Bob の画面を自分で再現するなら、別々のブラウザ、または通常ウィンドウとシークレットウィンドウを使い、それぞれ別のユーザーでログインしてください。同じブラウザの 2 つのタブはたいてい同じユーザーなので、presence では 1 人として数えられます。

3 つとも MIT ライセンスで、ソースは GitHub にあります:hibira-io/react、hibira-io/vue、hibira-io/nuxt。Pusher プロトコルで話すので、互換サーバーならどれでも使えます。すでに Laravel Echo を使っているプロジェクトなら、echo.connector.pusher を渡せば Echo と同じ接続を共有できます。

Free プランはクレジットカード不要です。プロダクトに AI アシスタントを加えようとしているなら、あるいは手書きの useEffect がリアルタイムのメッセージをたくさん処理しているなら、まずひとつのページをこの hooks に置き換えてみてください。ご意見があれば、GitHub の issue でぜひ教えてください。