# 不用 Action Cable 的 Hotwire：在 Rails 用 Hibira 傳送 Turbo Streams

> 我們為什麼做 hibira-rails：broadcasts_to 和 turbo_stream_from 照舊，WebSocket 連線交給 Hibira。它換掉的那一個方法、簽章串流如何維持安全、訊息過大時怎麼辦，以及五分鐘的實際操作。

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

在 Rails 裡做即時更新，最迷人的就是那一行程式碼：

```ruby
class Message < ApplicationRecord
  broadcasts_to :room
end
```

再在頁面上放一行 `<%= turbo_stream_from @room %>`，有人新增一則訊息，所有開著這個聊天室的瀏覽器就會同時多出一行，不用寫任何 JavaScript。這是 Hotwire 讓人愛上 Rails 的地方。

等到要上線時，問題才浮現：這一行的背後是 Action Cable。WebSocket 連線開在你自己的 Puma 裡，跨機器廣播要有 Redis 或 Solid Cable，部署平台有連線數上限，使用者一多，還得把 cable server 拆出去另外擴充。

這篇文章講的是我們做 `hibira-rails` 的經過：為什麼想做它、它換掉了哪一個點，以及怎麼在五分鐘內把現有的 Rails app 接上去。

## 那一行背後，其實是一整套伺服器

Action Cable 並不難用，難的是養它。每一位開著頁面的使用者，都占著你應用程式伺服器上的一條長連線。平常這沒什麼感覺，但在流量尖峰、部署重啟或伺服器換機時，這些連線會一起斷、一起重連，而你的 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，那就不叫「接上去」，而是「重寫一遍」。

所以我們給自己定了一個目標：**model 不改、view 不改，裝好之後，原本那一行就改走 Hibira。**

## 只換一個點

讀過 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 發布一次--> *Hibira|扇出 --WebSocket--> 瀏覽器
```

你的 Rails 程序從此只做它擅長的事：處理請求、渲染 HTML、發布一次。WebSocket 連線、重連和扇出都交給 Hibira。

## 安全規則不變：拿得到簽章，才訂閱得到

換掉傳輸層，最怕的是不小心把權限也換掉了。Action Cable 的規則很簡單：`turbo_stream_from` 會把串流名稱加上簽章放進頁面，伺服器只接受簽章正確的訂閱。能看到這個頁面的人，才拿得到這個簽章。

我們保留了一模一樣的規則。每一個 Turbo 串流對應 Hibira 上的一個私有頻道，頻道名稱由串流名稱雜湊而來。瀏覽器要訂閱時，必須帶著頁面上那個簽章回到你的 Rails app 換取授權；簽章不對，或頻道和串流對不上，就拒絕。

```text
sequence: 訂閱私有頻道時，簽章由你的 Rails app 驗證
瀏覽器 | 你的 Rails app | *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 事件
3 > 1: turbo-stream | 交給 Turbo 更新畫面
```

app secret 只存在你的伺服器上，瀏覽器拿到的只有公開的 app key。

## 跑一遍：五分鐘接上現有的 app

我們用一個全新的 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"`。

接著把 Hibira 後台 app 頁面上的三個值放進環境變數，或是 `bin/rails credentials:edit` 裡的 `hibira:`：

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

這裡有兩件事最容易卡住。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 手冊上的寫法，一個字都沒改。打開兩個視窗，在其中一個送出訊息，另一個不用重新整理就會出現：

![兩個瀏覽器視窗：在視窗 B 送出的訊息，立刻出現在視窗 A，中間沒有 Action Cable。](https://hibira.io/portal/articles/rails-turbo-two-windows-zh-TW.webp)

換頁之後也一樣。Turbo 換頁時，舊頁面的訂閱會先保留一下，如果新頁面訂閱的是同一個串流，就直接沿用，不會斷線重連。

## 測試不會壞

把廣播改送到外部服務，最容易出事的是測試：每跑一次測試就打一次網路，或是 Turbo 內建的 `assert_turbo_stream_broadcasts` 突然失效。

所以預設值是 `turbo = :auto`：只要設定了金鑰就改走 Hibira，**但在 test 環境裡維持 Action Cable 的行為**，你原本的廣播斷言照常通過，CI 也不會連到外部。想要強制開或關，設成 `true` 或 `false` 就好。

## 訊息太大的時候

這是我們做的過程中，最需要想清楚的一件事。Turbo Stream 送的是渲染好的 HTML，一張商品卡片可能幾百個位元組，一整段表格就可能好幾 KB。而每個方案都有單則事件的大小上限，Free 方案是 4 KB。

超過上限時，Hibira 會拒絕那則事件。如果什麼都不做，結果就是其他使用者的畫面悄悄不更新了，這是最糟的情況。

我們的做法是：遇到過大的廣播，自動改送一則 Turbo 8 的 page refresh。這一則只有幾十個位元組，收到的頁面會自己重新抓一次最新內容。畫面不會像 append 那樣只多出一行，但一定會是對的。

```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` 發使用者 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 app，歡迎試試看：註冊 [Hibira](https://hibira.io) 的免費方案不需要信用卡，裝好之後，那一行 `broadcasts_to` 就不用再由你的伺服器扛了。

我們還在為其他框架和引擎做類似的整合，下一個是誰，我們下次再揭露，敬請期待。
