# Hotwire โดยไม่ต้องใช้ Action Cable: ส่ง Turbo Streams ใน Rails ผ่าน Hibira

> ทำไมเราสร้าง hibira-rails: ใช้ broadcasts_to และ turbo_stream_from เหมือนเดิม ส่วนการเชื่อมต่อ WebSocket ให้ Hibira ดูแล เมธอดเดียวที่เปลี่ยน สตรีมที่ลงลายเซ็นยังปลอดภัยอย่างไร ข้อความที่ใหญ่เกินจะเป็นอย่างไร และการลองทำห้านาที

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

สิ่งที่มีเสน่ห์ที่สุดของการทำอัปเดตแบบเรียลไทม์ใน Rails คือโค้ดบรรทัดเดียวนี้

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

ใส่ `<%= turbo_stream_from @room %>` ไว้ในหน้าอีกบรรทัด ทุกครั้งที่มีคนโพสต์ข้อความ เบราว์เซอร์ทุกตัวที่เปิดห้องแชตนั้นอยู่จะมีบรรทัดใหม่เพิ่มขึ้นพร้อมกัน โดยไม่ต้องเขียน JavaScript เลยสักบรรทัด นี่คือจุดที่ Hotwire ทำให้คนหลงรัก Rails

ปัญหาจะโผล่มาตอนขึ้น production เพราะเบื้องหลังบรรทัดนั้นคือ Action Cable การเชื่อมต่อ WebSocket อยู่ใน Puma ของคุณเอง การกระจายข้อความข้ามเครื่องต้องมี Redis หรือ Solid Cable แพลตฟอร์มโฮสติ้งจำกัดจำนวนการเชื่อมต่อ และเมื่อผู้ใช้มากขึ้น คุณก็ต้องแยก cable server ออกไปขยายต่างหาก

บทความนี้เล่าเรื่องที่เราสร้าง `hibira-rails` ว่าทำไมเราอยากทำ มันเปลี่ยนแค่จุดเดียวตรงไหน และวิธีเชื่อมแอป Rails ที่มีอยู่แล้วภายในห้านาที

## เบื้องหลังบรรทัดเดียวนั้น คือเซิร์ฟเวอร์ทั้งชุด

Action Cable ไม่ได้ใช้ยาก ที่ยากคือการดูแลมัน ผู้ใช้แต่ละคนที่เปิดหน้าไว้จะครองการเชื่อมต่อระยะยาวหนึ่งเส้นบนเซิร์ฟเวอร์แอปของคุณ ปกติแทบไม่รู้สึก แต่ตอนทราฟฟิกพุ่ง ตอนรีสตาร์ตเพื่อ deploy หรือตอนเปลี่ยนเครื่อง การเชื่อมต่อเหล่านี้จะหลุดพร้อมกันและเชื่อมกลับพร้อมกัน ขณะที่โปรเซส 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` ที่รันใน background job สุดท้ายทั้งหมดจะไปจบที่เมธอดเดียวกันคือ `Turbo::StreamsChannel.broadcast_stream_to` ซึ่งส่ง HTML ของ Turbo Stream ที่เรนเดอร์แล้วให้ 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 stream แต่ละตัวจับคู่กับช่องส่วนตัวหนึ่งช่องบน Hibira โดยชื่อช่องสร้างจากค่าแฮชของชื่อสตรีม เมื่อเบราว์เซอร์จะสมัครรับ ต้องนำลายเซ็นจากหน้าเว็บกลับไปแลกการอนุญาตกับแอป 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 ซึ่งไม่ออกจากเซิร์ฟเวอร์
1 > 3: subscribe | private-turbo-…
2 > 3: broadcasts_to ทำงาน | อีเวนต์ turbo-stream หนึ่งรายการ
3 > 1: turbo-stream | ส่งให้ Turbo อัปเดตหน้า
```

app secret อยู่บนเซิร์ฟเวอร์ของคุณเท่านั้น เบราว์เซอร์ได้แค่ app key สาธารณะ

## ลองทำจริง: เชื่อมแอปที่มีอยู่ภายในห้านาที

เราสาธิตด้วยโปรเจกต์ Rails 8.1 ใหม่เอี่ยม และตั้งใจสร้างด้วย `--skip-action-cable` เพื่อแสดงว่าทั้งหมดนี้ไม่ต้องมีเซิร์ฟเวอร์ Action Cable

ติดตั้ง gem แล้วรันตัวติดตั้ง:

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

ตัวติดตั้งจะสร้าง `config/initializers/hibira.rb` ปักหมุด pusher-js ด้วย importmap (โปรเจกต์ jsbundling จะติดตั้งด้วยตัวจัดการแพ็กเกจของคุณ) และเพิ่ม `import "hibira/turbo"` หนึ่งบรรทัดใน `app/javascript/application.js`

จากนั้นนำค่าสามค่าจากหน้าแอปในแดชบอร์ด Hibira ใส่ในตัวแปรสภาพแวดล้อม หรือใต้ `hibira:` ใน `bin/rails credentials:edit`:

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

ตรงนี้มีสองเรื่องที่มักทำให้ติด อย่างแรก secret จะแสดงเพียงครั้งเดียวหลังสร้างแอป จึงต้องบันทึกไว้ทันที ส่วน app ID และ key ดูได้เสมอที่ **ภาพรวม → ข้อมูลรับรอง** ในหน้าแอป อย่างที่สอง แพ็กเกจ Free ใช้ `*` เป็น origin ที่อนุญาตไม่ได้ ต้องเพิ่มทุก origin ที่หน้าเว็บเปิดอยู่ เช่น `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-th.webp)

เปลี่ยนหน้าก็เหมือนกัน เมื่อ Turbo สลับหน้า การสมัครรับของหน้าเก่าจะถูกเก็บไว้ครู่หนึ่ง ถ้าหน้าใหม่สมัครรับสตรีมเดียวกันก็ใช้ต่อได้เลย ไม่ต้องตัดแล้วเชื่อมต่อใหม่

## เทสต์ไม่พัง

เมื่อการกระจายข้อความถูกส่งไปยังบริการภายนอก สิ่งที่พังง่ายที่สุดคือเทสต์ รันเทสต์ทีไรก็เรียกเครือข่ายทุกที หรือ `assert_turbo_stream_broadcasts` ที่มากับ Turbo ใช้ไม่ได้ขึ้นมาเฉยๆ

ค่าเริ่มต้นจึงเป็น `turbo = :auto` คือวิ่งผ่าน Hibira เมื่อตั้งคีย์ไว้ **แต่ในสภาพแวดล้อม test จะยังทำงานแบบ Action Cable** การตรวจสอบการกระจายข้อความที่มีอยู่ยังผ่านตามเดิม และ CI ก็ไม่ออกไปเรียกภายนอก ถ้าต้องการบังคับเปิดหรือปิด ให้ตั้งเป็น `true` หรือ `false`

## เมื่อข้อความใหญ่เกินไป

นี่คือส่วนที่เราต้องคิดให้รอบคอบที่สุด Turbo Stream ส่ง HTML ที่เรนเดอร์แล้ว การ์ดสินค้าหนึ่งใบอาจแค่ไม่กี่ร้อยไบต์ แต่ตารางทั้งตารางอาจหลาย KB และทุกแพ็กเกจมีขนาดสูงสุดต่ออีเวนต์ แพ็กเกจ Free อยู่ที่ 4 KB

เมื่อเกินขนาด Hibira จะปฏิเสธอีเวนต์นั้น ถ้าไม่จัดการอะไรเลย หน้าจอของผู้ใช้คนอื่นจะหยุดอัปเดตไปเงียบๆ ซึ่งเป็นผลลัพธ์ที่แย่ที่สุด

วิธีของเราคือ เมื่อเจอการกระจายข้อความที่ใหญ่เกิน จะส่ง page refresh ของ Turbo 8 แทนโดยอัตโนมัติ ซึ่งมีขนาดแค่ไม่กี่สิบไบต์ และหน้าที่ได้รับจะดึงเนื้อหาล่าสุดเอง หน้าจอจะไม่ได้แค่เพิ่มบรรทัดเดียวแบบ append แต่จะถูกต้องเสมอ

```text
flow: การกระจายข้อความที่เกินขนาดอีเวนต์จะกลายเป็น page refresh อัตโนมัติ
Model|broadcast_append_to --HTML 12 KB--> *Hibira|เกินขนาด ปฏิเสธ
*Hibira --แจ้งว่าใหญ่เกิน--> hibira-rails|ส่ง refresh แทน
hibira-rails --ไม่กี่สิบไบต์--> *Hibira --> เบราว์เซอร์|ดึงหน้าใหม่ ถูกต้องเสมอ
```

ถ้าหน้าของคุณเป็นรายการยาวอยู่แล้ว เราแนะนำให้ใช้ `broadcasts_refreshes` ของ Turbo 8 คู่กับ `turbo_refreshes_with method: :morph` ไปเลย การกระจายข้อความทุกครั้งจะเล็ก หน้าอัปเดตด้วย morph และตำแหน่งการเลื่อนไม่กระโดด ถ้าไม่อยากได้ refresh อัตโนมัติ ก็ตั้งให้บันทึก log แล้วทิ้ง หรือให้โยน exception ได้

## ไม่ใช่แค่ Turbo

ในเมื่อเชื่อม Rails กับ Hibira แล้ว เราจึงทำงานที่พบบ่อยอีกสองสามอย่างไว้ด้วย:

- **ช่องส่วนตัวและช่อง presence ของคุณเอง**: ตั้งค่า `authorize_channel` แล้วใช้ `current_user` ข้างในเพื่อกำหนดว่าใครสมัครรับช่องไหนได้ ถ้าเป็นช่อง presence ก็คืนข้อมูลผู้ใช้ มันสืบทอดจาก `ApplicationController` ของคุณ สถานะการล็อกอินและการป้องกัน CSRF จึงทำงานตามปกติ
- **Webhook**: รับ webhook ของ Hibira ที่ `/hibira/webhooks` พร้อมตรวจลายเซ็นให้ ในช่วงผ่อนผันระหว่างหมุนเวียน secret ก็ยังรับ webhook ที่เซ็นด้วย secret เก่า จึงไม่พลาดอีเวนต์ก่อน deploy ใหม่
- **การแจ้งเตือนแบบพุช**: `Hibira.push` ส่งพุชไปยัง iOS, Android และเบราว์เซอร์ และ `/hibira/push/token` ออกโทเค็นผู้ใช้ให้ SDK

## สิ่งที่รองรับ

- Rails 7.0 ถึง 8.1, Ruby 3.1 ขึ้นไป, turbo-rails 1.5 ถึง 2 ทุกชุดผสมรันใน CI
- ใช้ได้ทั้ง importmap และ jsbundling (ตรวจสอบ assets:precompile กับ Propshaft แล้ว)
- ใช้ได้กับโปรเจกต์ที่สร้างด้วย `--skip-action-cable`

`hibira-rails` เผยแพร่บน [RubyGems](https://rubygems.org/gems/hibira-rails) แล้ว และซอร์สโค้ดอยู่บน [GitHub](https://github.com/hibira-io/rails) ภายใต้สัญญาอนุญาต MIT ถ้าคุณมีแอป Hotwire ที่พึ่ง Action Cable อยู่ ลองใช้ดู แพ็กเกจฟรีของ [Hibira](https://hibira.io) ไม่ต้องใช้บัตรเครดิต และเมื่อติดตั้งแล้ว `broadcasts_to` บรรทัดนั้นก็ไม่ต้องให้เซิร์ฟเวอร์ของคุณแบกอีกต่อไป

เรากำลังทำการเชื่อมต่อแบบเดียวกันให้เฟรมเวิร์กและเอนจินอื่นๆ ตัวต่อไปคืออะไร เราจะเปิดเผยในบทความหน้า โปรดติดตาม
