← ブログ

Action Cable なしの Hotwire:Rails の Turbo Streams を Hibira で配信

· Hibira

hibira-rails を作った理由:broadcasts_to と turbo_stream_from はそのままに、WebSocket の接続は Hibira が担います。差し替えた 1 つのメソッド、署名付きストリームの安全性、大きすぎるブロードキャストの扱い、そして 5 分の手順。

Rails でリアルタイム更新を作るとき、いちばん魅力的なのはこの 1 行です。

class Message < ApplicationRecord
  broadcasts_to :room
end

ページに <%= turbo_stream_from @room %> を 1 行置けば、誰かがメッセージを投稿した瞬間、そのチャットルームを開いているすべてのブラウザに 1 行が同時に追加されます。JavaScript は 1 行も書きません。Hotwire が Rails を好きにさせるところです。

問題が見えてくるのは本番に出すときです。この 1 行の裏には Action Cable がいます。WebSocket の接続は自分の Puma の中に張られ、複数台にまたがるブロードキャストには Redis か Solid Cable が必要です。ホスティング先には接続数の上限があり、ユーザーが増えれば cable server を切り出して別にスケールさせることになります。

この記事では、私たちが hibira-rails を作った経緯をお話しします。なぜ作ろうと思ったのか、どこを 1 か所だけ差し替えたのか、そして既存の Rails アプリを 5 分でつなぐ手順です。

その 1 行の裏には、サーバーがまるごと 1 つある

Action Cable は使うのが難しいわけではありません。難しいのは運用です。ページを開いているユーザー 1 人ひとりが、アプリケーションサーバー上の長時間接続を 1 本ずつ占有します。普段は気になりませんが、アクセスの急増、デプロイによる再起動、サーバーの入れ替えのたびに、これらの接続が一斉に切れて一斉に再接続し、Rails のプロセスは通常のリクエストとこの再接続の波を同時に処理しなければなりません。

ブラウザ数千本の WebSocket
長時間接続
自分の PumaHTTP と WebSocket を同時に処理
発行
Redis か Solid Cable複数台で同期
Redis か Solid Cable
中継
自分の Puma
配信
ブラウザ
Action Cable では、接続もブロードキャストも自分のインフラの中にある

私たちが Hibira を始めたのは、この層をチームが自分で運用しなくて済むようにするためです。Hibira は Pusher Channels プロトコル互換のホスティング型リアルタイム配信サービスなので、pusher-js などの既存ライブラリがそのまま接続できます。ただ、Rails の開発者が使っているのは pusher-js ではなく、broadcasts_to と turbo_stream_from です。サービスを乗り換えるためにすべての model と view を書き直す必要があるなら、それは「つなぐ」ではなく「作り直す」です。

そこで目標を 1 つだけ決めました。model も view も変えない。入れたら、あの 1 行がそのまま Hibira を通る。

差し替えるのは 1 か所だけ

turbo-rails のソースを読むとわかりますが、broadcasts_to、broadcast_append_to、broadcasts_refreshes、バックグラウンドジョブで動く broadcast_replace_later_to のどれを使っても、最後は同じメソッド Turbo::StreamsChannel.broadcast_stream_to にたどり着きます。ここがレンダリング済みの Turbo Stream の HTML を Action Cable に渡しています。

これがとてもきれいな差し込み口になりました。hibira-rails が上書きするのはこのメソッドだけで、同じ HTML を Hibira のチャンネルへ送るようにします。ページ側も同じです。turbo_stream_from はこれまで <turbo-cable-stream-source> を出力していましたが、代わりに <hibira-turbo-stream-source> を出力し、小さな JavaScript がチャンネルを購読して、届いた内容を Turbo に渡します。Turbo 自身は、メッセージがどこから来たのかをまったく知りません。

導入前

Modelbroadcasts_to
TurboStreamsChannel
Action Cable自分の Puma と Redis
WebSocket
ブラウザ

導入後

Modelbroadcasts_to
TurboStreamsChannel
HTTPS で 1 回発行
Hibiraファンアウト
WebSocket
ブラウザ
hibira-rails を入れると、同じ broadcasts_to が Hibira を通る

これで Rails のプロセスは得意なことだけをすればよくなります。リクエストを処理し、HTML をレンダリングし、1 回発行するだけです。WebSocket の接続、再接続、ファンアウトは Hibira が引き受けます。

セキュリティのルールは同じ:署名がなければ購読できない

トランスポートを差し替えるときにいちばん怖いのは、権限まで一緒に変えてしまうことです。Action Cable のルールはシンプルで、turbo_stream_from は署名付きのストリーム名をページに埋め込み、サーバーは署名が正しい購読だけを受け付けます。そのページを見られる人だけが署名を手にできます。

私たちはまったく同じルールを残しました。Turbo のストリーム 1 つにつき、Hibira 上のプライベートチャンネルが 1 つ対応し、チャンネル名はストリーム名のハッシュから作ります。ブラウザが購読するときは、ページにある署名を Rails アプリに持っていって認可と交換しなければなりません。署名が正しくない場合や、チャンネルとストリームが一致しない場合は拒否されます。

ブラウザRails アプリHibira署名付きストリーム名を含むページturbo_stream_from接続socket_id を取得POST /hibira/turbo/authsocket_id、チャンネル、署名認可の署名app secret で署名、secret はサーバーから出ないsubscribeprivate-turbo-…broadcasts_to が発火turbo-stream イベント 1 件turbo-streamTurbo に渡して画面を更新
プライベートチャンネルの購読時、署名は Rails アプリが検証する

app secret はサーバーにしかありません。ブラウザが受け取るのは公開用の app key だけです。

実際にやってみる:既存アプリを 5 分でつなぐ

まっさらな Rails 8.1 プロジェクトで試します。作成時にあえて --skip-action-cable を付けて、Action Cable サーバーがなくても動くことを確かめます。

gem をインストールして、インストーラーを実行します。

bundle add hibira-rails
bin/rails generate hibira:install

インストーラーは config/initializers/hibira.rb を作り、importmap で pusher-js をピン留めし(jsbundling のプロジェクトではパッケージマネージャーでインストールし)、app/javascript/application.js に import "hibira/turbo" を 1 行追加します。

続いて、Hibira の管理画面のアプリページにある 3 つの値を環境変数か、bin/rails credentials:edit の hibira: に設定します。

HIBIRA_APP_ID=your-app-id
HIBIRA_APP_KEY=your-app-key
HIBIRA_APP_SECRET=your-app-secret

ここでつまずきやすい点が 2 つあります。secret はアプリを作成した直後に一度しか表示されないので、その場で保存してください。app ID と key は、アプリページの概要 → 認証情報でいつでも確認できます。また、フリープランでは許可するオリジンに * を使えません。開発中の http://localhost:3000 と本番のドメインなど、ページを開くオリジンをすべて追加してください。追加していないと、ブラウザの接続が拒否されます。

そして確認します。

$ bin/rails hibira:check
ok    App 1 on ws.hibira.io:443
ok    Credentials accepted by the REST API
ok    Turbo Streams go through Hibira
info  Push notifications off (set HIBIRA_PUSH_INSTANCE_ID and HIBIRA_PUSH_SECRET_KEY)

model と view は Turbo のハンドブックどおりで、1 文字も変えていません。ウィンドウを 2 つ開いて片方からメッセージを送ると、もう片方にリロードなしで表示されます。

2 つのブラウザウィンドウ:ウィンドウ B から送ったメッセージが、すぐにウィンドウ A に表示される。間に Action Cable はない。
2 つのブラウザウィンドウ:ウィンドウ B から送ったメッセージが、すぐにウィンドウ A に表示される。間に Action Cable はない。

ページを移動しても同じです。Turbo がページを切り替えるとき、前のページの購読は少しのあいだ残しておき、新しいページが同じストリームを購読するならそのまま使い回すので、切断と再接続は起きません。

テストは壊れない

ブロードキャストを外部サービスに送るようにすると、真っ先に壊れがちなのがテストです。テストのたびにネットワークへアクセスしたり、Turbo 組み込みの assert_turbo_stream_broadcasts が急に通らなくなったりします。

そこでデフォルトは turbo = :auto にしました。キーが設定されていれば Hibira を通しますが、test 環境では Action Cable の動作のままです。既存のブロードキャストのアサーションはそのまま通り、CI も外部にアクセスしません。強制的にオンまたはオフにしたい場合は true か false を設定します。

メッセージが大きすぎるとき

開発の中でいちばん考え抜いたのがここです。Turbo Stream が送るのはレンダリング済みの HTML です。商品カード 1 枚なら数百バイトでも、表をまるごと送れば数 KB になります。そして各プランには 1 イベントあたりのサイズ上限があり、Free プランは 4 KB です。

上限を超えると、Hibira はそのイベントを拒否します。何も対処しなければ、ほかのユーザーの画面が黙って更新されなくなります。これが最悪のケースです。

私たちの対処はこうです。大きすぎるブロードキャストは、自動的に Turbo 8 の page refresh に切り替えて送ります。これはわずか数十バイトで、受け取ったページが最新の内容を自分で取り直します。append のように 1 行だけ増えるわけではありませんが、画面は必ず正しくなります。

Modelbroadcast_append_to
12 KB の HTML
Hibira上限超過で拒否
Hibira
大きすぎると通知
hibira-railsrefresh を代わりに送る
hibira-rails
数十バイト
Hibira
ブラウザページを取り直すので、画面は必ず正しい
イベントの上限を超えたブロードキャストは page refresh に切り替える

もともと長いリストのページなら、Turbo 8 の broadcasts_refreshes と turbo_refreshes_with method: :morph の組み合わせをおすすめします。ブロードキャストはどれも小さく、画面は morph で更新され、スクロール位置も飛びません。自動 refresh が不要なら、ログに記録して破棄する設定や、例外を投げる設定も選べます。

Turbo だけではない

Rails と Hibira をつないだついでに、よくある作業もいくつか用意しました。

  • 独自のプライベートチャンネルと presence チャンネル:authorize_channel を設定し、その中で current_user を使って誰がどのチャンネルを購読できるかを決めます。presence チャンネルならユーザー情報を返します。ApplicationController を継承するので、ログイン状態も CSRF 対策もいつもどおり効きます。
  • Webhook:/hibira/webhooks で Hibira の webhook を受け取り、署名を自動で検証します。secret のローテーションの猶予期間中は古い secret で署名されたものも受け付けるので、再デプロイ前にイベントを取りこぼしません。
  • プッシュ通知:Hibira.push で iOS、Android、ブラウザにプッシュを送り、/hibira/push/token で SDK 用のユーザートークンを発行します。

対応範囲

  • Rails 7.0 から 8.1、Ruby 3.1 以上、turbo-rails 1.5 から 2。すべての組み合わせを CI で動かしています。
  • importmap と jsbundling のどちらでも使えます(Propshaft での assets:precompile は確認済み)。
  • --skip-action-cable で作ったプロジェクトでも使えます。

hibira-rails は RubyGems で公開済みで、ソースコードは MIT ライセンスで GitHub にあります。Action Cable に支えられている Hotwire アプリをお持ちなら、ぜひ試してみてください。Hibira のフリープランはクレジットカード不要です。入れてしまえば、あの broadcasts_to の 1 行を自分のサーバーで背負う必要はもうありません。

ほかのフレームワークやエンジン向けにも同じような連携を作っています。次はどれなのか、次回の記事で明かします。お楽しみに。