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

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

Source: https://hibira.io/ja/blog/rails-turbo-streams
Updated: 2026-11-10

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

```ruby
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 のプロセスは通常のリクエストとこの再接続の波を同時に処理しなければなりません。

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

私たちが 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 自身は、メッセージがどこから来たのかをまったく知りません。

```text
flow: hibira-rails を入れると、同じ broadcasts_to が Hibira を通る
== 導入前
Model|broadcasts_to --> Turbo|StreamsChannel --> Action Cable|自分の Puma と Redis --WebSocket--> ブラウザ
== 導入後
Model|broadcasts_to --> Turbo|StreamsChannel --HTTPS で 1 回発行--> *Hibira|ファンアウト --WebSocket--> ブラウザ
```

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

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

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

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

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

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

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

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

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

```sh
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:` に設定します。

```sh
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` と本番のドメインなど、ページを開くオリジンをすべて追加してください。追加していないと、ブラウザの接続が拒否されます。

そして確認します。

```text
$ 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 はない。](https://hibira.io/portal/articles/rails-turbo-two-windows-ja.webp)

ページを移動しても同じです。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 行だけ増えるわけではありませんが、画面は必ず正しくなります。

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

もともと長いリストのページなら、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](https://rubygems.org/gems/hibira-rails) で公開済みで、ソースコードは MIT ライセンスで [GitHub](https://github.com/hibira-io/rails) にあります。Action Cable に支えられている Hotwire アプリをお持ちなら、ぜひ試してみてください。[Hibira](https://hibira.io) のフリープランはクレジットカード不要です。入れてしまえば、あの `broadcasts_to` の 1 行を自分のサーバーで背負う必要はもうありません。

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