วิธีต่อ Next.js กับ Laravel Broadcasting ผ่าน Reverb

เบราว์เซอร์ต่อ Reverb เองโดยไม่ผ่าน Next.js server เลือกได้ระหว่าง hook ของ @laravel/echo-react หรือสร้าง Echo เองเมื่อต้องใช้นอก component ขอสิทธิ์ private channel ตามวิธีล็อกอินที่ใช้ และต้องมีทางโหลดข้อมูลใหม่ เพราะ Reverb ไม่ส่ง event ที่หลุดไปตอนสายขาดซ้ำให้

เผยแพร่
เวลาอ่าน
6 นาที
ในโพสต์นี้ 9 หัวข้อ
  1. เบราว์เซอร์ต่อ Reverb โดยตรง
  2. มีให้เลือกสี่ทาง
  3. เตรียมฝั่ง Laravel สามอย่าง
  4. ทางแรก ใช้ hook ของ echo-react
  5. ทางที่สอง สร้าง Echo เองเมื่อต้องฟังนอก component
  6. ขอสิทธิ์ private channel ตามวิธีล็อกอินที่ใช้
  7. พอ event มาถึงให้โหลดข้อมูลใหม่
  8. event ที่ส่งตอนสายหลุดจะไม่มาอีก
  9. เรื่องที่อาจเจอเมื่อใช้งานจริง

ตอนตั้งค่าฝั่ง Laravel เราให้ Laravel ส่ง event เข้า Reverb แล้ว ถ้าหน้าเว็บอยู่ใน repo เดียวกับ Laravel ตั้ง Echo ไม่กี่บรรทัดก็จบ แต่ถ้าเป็น Next.js ที่แยกออกไปอีกแอป จะไม่มีทั้ง session ของ Laravel และ VITE_ แล้ว Next.js ยัง render component บน server ก่อนด้วย โพสต์นี้รวมสิ่งที่ต้องทำเพิ่ม ตัวอย่างหลักลองรันกับ Next.js 16, Laravel 13 และ Reverb 1.12 แล้ว

เบราว์เซอร์ต่อ Reverb โดยตรง

เบราว์เซอร์ต่อ Reverb เอง ส่วนการขอสิทธิ์วิ่งไปที่ Laravel

Next.js server ไม่ต้องถือสาย WebSocket เลย มีหน้าที่อย่างมากแค่ช่วยขอสิทธิ์

จุดนี้ทำให้เรื่องง่ายขึ้นมาก เพราะที่ถือสายคือ Reverb ที่อยู่กับ Laravel ต่อให้ Next.js โฮสต์บนบริการที่รัน WebSocket server เองไม่ได้ก็ไม่มีผล สิ่งที่ต่างจากหน้าเว็บในโปรเจกต์ Laravel มีสี่เรื่อง

เรื่อง หน้าเว็บในโปรเจกต์ Laravel Next.js ที่แยกแอป
ค่าตั้งต้นของ Echo อ่าน VITE_REVERB_* ให้เอง ต้องส่ง NEXT_PUBLIC_REVERB_* เข้าไปเอง
ที่ที่โค้ดรัน เบราว์เซอร์อย่างเดียว (ถ้าไม่เปิด SSR) render บน server ก่อน ต้องกันไม่ให้ต่อ socket จากฝั่งนั้น
ขอสิทธิ์ private channel session cookie Bearer token หรือ cookie ข้ามโดเมน
โดเมนที่ต่อเข้า Reverb โดเมนเดียวกับ Laravel โดเมนของหน้าเว็บ ต้องเพิ่มใน allowed_origins

มีให้เลือกสี่ทาง

ทาง ใช้เมื่อ ต้องเขียนเอง
@laravel/echo-react งานใหม่ ฟังจาก component ตั้งค่าตอนเริ่ม
laravel-echo + pusher-js ฟังจาก store นอก component จัดการ subscribe และ cleanup
pusher-js อย่างเดียว ไม่อยากมี Echo ต้องเติม private- เองและจัดการ event เอง
Pusher หรือ Ably แทน Reverb รัน Reverb เองไม่ได้ เปลี่ยน broadcaster และค่า key ส่วน hook ยังเหมือนเดิม

ทางแรกสั้นที่สุด ส่วนหน้าเว็บของระบบที่เราดูแลใช้ทางที่สอง เพราะ state ของแชตอยู่ใน zustand store นอก component

บริการที่โฮสต์ให้มีแผนฟรีพอทดลองได้ ตอนที่เขียนนี้ Pusher Channels แผน Sandbox รับ 100 สายพร้อมกัน 200,000 ข้อความต่อวัน แผนถัดไปเริ่ม $49 ต่อเดือน ส่วน Ably แผนฟรีรับ 200 สาย 6 ล้านข้อความต่อเดือน แผนถัดไปเริ่ม $29 ต่อเดือนบวกค่าใช้งาน ระบบที่มีผู้ใช้เปิดหน้าทิ้งไว้ทั้งวันจะชนเพดานจำนวนสายก่อนจำนวนข้อความ

เตรียมฝั่ง Laravel สามอย่าง

ย้าย route ขอสิทธิ์ไปไว้ใต้ api ให้ใช้ Sanctum แล้วลบ channels: ออกจาก withRouting ถ้าไม่ลบจะมี broadcasting/auth ตัวเดิมค้างอยู่อีก route และตัวนั้นจะตอบ 403 ให้ Bearer token

->withBroadcasting(
    __DIR__.'/../routes/channels.php',
    ['prefix' => 'api', 'middleware' => ['api', 'auth:sanctum']],
)

เพิ่มโดเมนหน้าเว็บใน allowed_origins ของ config/reverb.php ถ้ามี preview deploy ใส่ *.example.com ได้ และให้ CORS รับโดเมนเดียวกัน ถ้ายังไม่มี config/cors.php สั่ง php artisan config:publish cors ก่อน ค่า api/* ครอบ route นี้อยู่แล้ว

ฝั่ง Next.js ใส่ค่าที่เปิดเผยได้ใน .env ส่วน REVERB_APP_SECRET ต้องอยู่แค่ฝั่ง Laravel ห้ามมี NEXT_PUBLIC_ เด็ดขาด key เป็นแค่ชื่อที่บอกว่าต่อแอปไหน เปิดเผยได้

NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_REVERB_APP_KEY=xxxxxxxx
NEXT_PUBLIC_REVERB_HOST=api.example.com
NEXT_PUBLIC_REVERB_PORT=443
NEXT_PUBLIC_REVERB_SCHEME=https

ทางแรก ใช้ hook ของ echo-react

npm install @laravel/echo-react pusher-js
import { configureEcho } from '@laravel/echo-react'
import { useToken } from '@/store/token'

const authorize = ({ socketId, channelName }, callback) => {
  fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/broadcasting/auth`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Accept: 'application/json',
      Authorization: `Bearer ${useToken.getState().token}`,
    },
    body: JSON.stringify({ socket_id: socketId, channel_name: channelName }),
  })
    .then((response) =>
      response.json().then((data) =>
        response.ok ? callback(null, data) : callback(new Error(data.message), null)
      )
    )
    .catch((error) => callback(error, null))
}

if (typeof window !== 'undefined') {
  configureEcho({
    broadcaster: 'reverb',
    key: process.env.NEXT_PUBLIC_REVERB_APP_KEY,
    wsHost: process.env.NEXT_PUBLIC_REVERB_HOST,
    wsPort: Number(process.env.NEXT_PUBLIC_REVERB_PORT),
    wssPort: Number(process.env.NEXT_PUBLIC_REVERB_PORT),
    forceTLS: process.env.NEXT_PUBLIC_REVERB_SCHEME === 'https',
    enabledTransports: ['ws', 'wss'],
    channelAuthorization: { customHandler: authorize },
  })
}

ในโค้ดสองไฟล์นี้มีสามจุดที่พลาดแล้วเห็นผลทันที

  • ต้องมีเงื่อนไข typeof window ถ้าไม่มี Next.js จะเปิด socket จริงจาก server ตั้งแต่ตอน build แล้วหน้าเว็บจะ hydrate ไม่ตรงกัน เพราะ server แสดง connected ขณะที่เบราว์เซอร์ยัง connecting
  • useEcho ปลอดภัยตอน SSR แต่ useConnectionStatus ไม่ปลอดภัย ตัวหลังเรียก Echo ตั้งแต่ render แรก จึงโยน Echo has not been configured บน server หน้า static จะ build ไม่ผ่าน หน้า dynamic จะตอบ 500 จึงต้องแยกไว้ใน component ที่แสดงหลัง mount แบบในตัวอย่าง
  • ชื่อ event ต้องมีจุดนำหน้า เมื่อฝั่ง Laravel ใช้ broadcastAs() ถ้าเขียน OrderStatusUpdated ไม่มีจุด Echo จะเติม namespace App.Events ให้ แล้วไม่มีอะไรเข้ามาเลยโดยไม่มี error

ทางที่สอง สร้าง Echo เองเมื่อต้องฟังนอก component

ถ้า state อยู่ใน store หรือมีหลายจุดใช้สายเดียวกัน สร้าง instance เดียวไว้ แล้วเรียกตอนที่ใช้จริงเท่านั้น โค้ดฝั่ง server จึงไม่แตะ WebSocket เลย

import Echo from 'laravel-echo'
import Pusher from 'pusher-js'

let instance = null

export const getEcho = () => {
  if (typeof window === 'undefined') {
    return null
  }
  if (!instance) {
    instance = new Echo({
      broadcaster: 'reverb',
      Pusher,
      key: process.env.NEXT_PUBLIC_REVERB_APP_KEY,
      wsHost: process.env.NEXT_PUBLIC_REVERB_HOST,
      wsPort: Number(process.env.NEXT_PUBLIC_REVERB_PORT),
      wssPort: Number(process.env.NEXT_PUBLIC_REVERB_PORT),
      forceTLS: process.env.NEXT_PUBLIC_REVERB_SCHEME === 'https',
      enabledTransports: ['ws', 'wss'],
      channelAuthorization: { customHandler: authorize },
    })
  }
  return instance
}

export const disconnectEcho = () => {
  instance?.disconnect()
  instance = null
}
useEffect(() => {
  const channel = getEcho().private(`user.${userId}`)

  channel.listen('.OrderStatusUpdated', handleUpdate)

  return () => channel.stopListening('.OrderStatusUpdated', handleUpdate)
}, [userId])

ตอน cleanup ให้ใช้ stopListening() อย่าใช้ echo.leave() เพราะ leave() ออกจาก channel ทั้ง instance ตอนเราลองให้สอง component ฟัง channel เดียวกัน พอตัวหนึ่ง unmount อีกตัวก็เงียบไปด้วย และเงียบต่อแม้ตัวแรกจะ mount กลับมา ยังเจอ event เข้าซ้ำสองครั้งตอน mount ใหม่ขณะสายเปิดอยู่ด้วย เก็บ leave() กับ disconnectEcho() ไว้ใช้ตอน logout ส่วน hook ของทางแรกนับจำนวนคนฟังให้เองอยู่แล้ว

ถ้าไม่อยากมี Echo เลย pusher-js อย่างเดียวก็ต่อได้ เพราะ Reverb คุย protocol เดียวกับ Pusher ต้องใส่ cluster ไว้ด้วยแม้จะเป็นค่าว่าง ไม่อย่างนั้นจะโยน error ตอนเริ่ม แล้ว subscribe private-user.1 ด้วยชื่อเต็มเอง

ขอสิทธิ์ private channel ตามวิธีล็อกอินที่ใช้

วิธีล็อกอิน ขอสิทธิ์แบบไหน
token อยู่ใน memory ของหน้าเว็บ customHandler ส่ง Bearer ตรงไป Laravel แบบในตัวอย่างข้างบน
token อยู่ใน cookie แบบ httpOnly ให้ Route Handler ของ Next.js เติม Bearer ให้
cookie session ของ Sanctum ส่ง cookie ข้ามโดเมนย่อยไปกับ request และต้องมี CSRF token ตามเอกสาร Sanctum

แบบที่สอง JavaScript อ่าน token ไม่ได้ จึงให้ Next.js server เป็นคนเติมให้ แล้วชี้ Echo มาที่ route นี้ด้วย authEndpoint: '/api/broadcasting/auth' แทน channelAuthorization

import { cookies } from 'next/headers'

export async function POST(request) {
  const token = (await cookies()).get('token')?.value

  const response = await fetch(`${process.env.API_URL}/api/broadcasting/auth`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: 'application/json',
      'Content-Type': request.headers.get('content-type'),
    },
    body: await request.text(),
  })

  return new Response(await response.text(), {
    status: response.status,
    headers: { 'Content-Type': 'application/json' },
  })
}

ถ้าไม่มีสิทธิ์ Laravel ตอบ 403 ส่วน channel อื่นบนสายเดียวกันยังทำงานต่อ ดู error ได้จาก channel.error() จุดที่ต้องดูอีกอย่างคือแบบแรก ถ้า route นี้รับทั้ง session และ token ให้ส่ง request โดยไม่แนบ cookie ไม่อย่างนั้นแอดมินที่ล็อกอินหลังบ้านค้างไว้จะกลายเป็นคนละคน

พอ event มาถึงให้โหลดข้อมูลใหม่

ถ้าฝั่ง Laravel ส่งแบบสะกิด หน้าเว็บก็แค่สั่งโหลดใหม่ ถ้าใช้ SWR สั่ง mutate() ถ้าข้อมูลมาจาก Server Component สั่ง router.refresh() ให้ Next.js ดึงหน้าใหม่จาก server

'use client'

import '@/lib/echo'
import { useRouter } from 'next/navigation'
import { useEcho } from '@laravel/echo-react'

export default function LiveRefresh({ userId }) {
  const router = useRouter()

  useEcho(`user.${userId}`, '.OrderStatusUpdated', () => router.refresh())

  return null
}

วาง component นี้ไว้ใน Server Component ได้เลย สิ่งที่ต้องระวังคือหน้านั้นต้องเป็น dynamic เช่น fetch ด้วย cache: 'no-store' เพราะหน้า static ที่ prerender ไว้ router.refresh() จะได้ของเดิมกลับมา

event ที่ส่งตอนสายหลุดจะไม่มาอีก

pusher-js ต่อกลับเองและขอสิทธิ์ channel ให้ใหม่ เราไม่ต้องเขียนอะไร แต่ Reverb ไม่เก็บ event ย้อนหลัง ตอนที่ลองปิด Reverb ราว 8 วินาที สายกลับมาหลังผ่านไป 15 วินาที ปิด 45 วินาที ก็กลับมาที่ราว 46 วินาที event ที่ Reverb รับหลังกลับมาแล้ว แต่เบราว์เซอร์ยังรอรอบ retry อยู่ก็หายไปด้วย รวมถึงตอนมือถือพักหน้าจอหรือเน็ตสะดุดก็เป็นแบบเดียวกัน

หน้าเว็บจึงต้องมีสองอย่าง คือ polling ตอนที่สายยังไม่ต่อ และโหลดใหม่ทันทีตอนที่ต่อกลับมาได้

const { data, mutate } = useSWR('/api/orders', fetcher, {
  refreshInterval: status === 'connected' ? 0 : 60_000,
})

useEffect(() => {
  const connection = getEcho().connector.pusher.connection

  const handleChange = ({ current }) => {
    setStatus(current)
    if (current === 'connected') {
      mutate()
    }
  }

  setStatus(connection.state)
  connection.bind('state_change', handleChange)

  return () => connection.unbind('state_change', handleChange)
}, [mutate])

ต้องตั้งค่าเริ่มจาก connection.state ด้วย เพราะ component ที่ mount หลังสายต่อแล้วจะไม่เห็น state_change อีก ถ้าข้อมูลเยอะจนโหลดใหม่ทั้งหมดไม่ไหว อย่างรายการแชตหลายร้อยห้อง ให้ API รับ since แล้วส่งเฉพาะที่เปลี่ยนหลังเวลานั้น ระบบที่เราทำใช้เวลาจาก server_time ของ response ก่อนหน้า ถ้าเปลี่ยนเกิน 200 ห้องก็บอกให้โหลดหน้าแรกใหม่

เรื่องที่อาจเจอเมื่อใช้งานจริง

  • สายเปิดแล้วหลุดทันที และไม่ลองใหม่อีกเลย ถ้าใน WebSocket มี error 4009 Origin not allowed แปลว่าโดเมนหน้าเว็บไม่อยู่ใน allowed_origins จุดที่พลาดบ่อยคือใส่ https:// หรือพอร์ตมาด้วย ค่าพวกนั้นไม่มีวันตรง ส่วน localhost:3000 ให้ใส่แค่ localhost
  • console ขึ้น You must pass your app key หรือต่อไปที่ ws-.pusher.com แปลว่า key หรือ wsHost เป็นค่าว่าง เช็ก NEXT_PUBLIC_REVERB_* ก่อน ค่าพวกนี้ฝังลงไฟล์ตอน build แก้แล้วต้อง build ใหม่ และ error ตัวแรกโยนเป็น string err.message จึงเป็น undefined ใน error tracker อาจเห็นไม่ครบ
  • ตอนพัฒนา console มี error เรื่อง wss:// แม้จะตั้ง forceTLS: false เกิดจาก enabledTransports ที่มี wss ด้วย ไม่กระทบการทำงาน ถ้ารำคาญให้ใส่แค่ ['ws'] ในเครื่อง
  • ผู้ใช้ logout แล้วสายยังเปิดค้าง channel ที่เข้าไว้ยังรับ event ของคนเดิมอยู่ ให้เรียก disconnectEcho() ตอน logout และถ้าต้องขอสิทธิ์ใหม่ authorize อ่าน token ล่าสุดจาก store ทุกครั้งอยู่แล้ว

จบซีรีส์ Broadcasting และ Reverb ใน Laravel แล้ว

ดูทั้ง 3 ตอน →

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →