← กรณีการใช้งาน

Next.js บน Vercel และ Serverless

เผยแพร่จาก Server Action สมัครรับใน Client Component ไม่ต้องให้ฟังก์ชันถือ socket

แอป Next.js ที่รันบน Vercel, Netlify, Cloudflare หรือ AWS Lambda จะรันโค้ดฝั่งเซิร์ฟเวอร์ทีละคำขอ ฟังก์ชันที่มีชีวิตอยู่ไม่กี่ร้อยมิลลิวินาทีเปิดการเชื่อมต่อ WebSocket นับพันค้างไว้ไม่ได้ "เพิ่มการอัปเดตสด" จึงมักจบที่การ polling หรือเซิร์ฟเวอร์แยกที่ต้องรันเอง เมื่อใช้ Hibira, Route Handler และ Server Action ของคุณเผยแพร่ด้วยการเรียก HTTPS สั้น ๆ ครั้งเดียว และเบราว์เซอร์คงการเชื่อมต่อไว้กับ Hibira แทนที่จะเป็นฟังก์ชันของคุณ

สิ่งที่ผู้ใช้ได้รับ

  • สถานะคำสั่งซื้อ งาน และการชำระเงินเปลี่ยนบนหน้าจอทันทีที่เปลี่ยนในฐานข้อมูล
  • การแจ้งเตือนและความคิดเห็นปรากฏในทุกแท็บที่เปิดอยู่ รวมถึงแท็บที่เปิดจากลิงก์ที่แชร์
  • หน้ายังเร็วเหมือนเดิม การเรนเดอร์ครั้งแรกยังมาจาก Server Component ของคุณ

ฟีเจอร์ที่ใช้

  • pusher (ไลบรารีฝั่งเซิร์ฟเวอร์ของ Node.js) ใน Route Handler และ Server Action
  • pusher-js ใน Client Component
  • แชนเนลส่วนตัว (private-) ที่ยืนยันสิทธิ์โดย Route Handler ซึ่งตรวจ session ของคุณ
  • การไม่ส่งกลับไปหาผู้ส่ง (socket_id) ผู้ใช้ที่ทำการเปลี่ยนแปลงจะไม่ได้รับซ้ำสองครั้ง

ขั้นที่ 1: ตัวแปรสภาพแวดล้อม

PUSHER_APP_ID=YOUR_APP_ID
PUSHER_APP_SECRET=YOUR_APP_SECRET
NEXT_PUBLIC_PUSHER_KEY=YOUR_APP_KEY
NEXT_PUBLIC_PUSHER_HOST=ws.hibira.io

มีแค่ key และ host ที่ใส่คำนำหน้า NEXT_PUBLIC_ เพราะสองค่านี้ถูกส่งไปที่เบราว์เซอร์ ส่วน secret ไม่มีวันใส่

ขั้นที่ 2: ไคลเอนต์ที่ใช้เฉพาะฝั่งเซิร์ฟเวอร์

// lib/pusher-server.ts
import 'server-only';
import Pusher from 'pusher';

export const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID!,
  key: process.env.NEXT_PUBLIC_PUSHER_KEY!,
  secret: process.env.PUSHER_APP_SECRET!,
  host: process.env.NEXT_PUBLIC_PUSHER_HOST!,
  useTLS: true,
});

ติดตั้งไลบรารีด้วย npm install pusher pusher-js server-only การ import server-only ทำให้ build ล้มเหลวทันทีถ้ามี Client Component ใด import ไฟล์นี้ secret จึงไม่มีทางหลุดไปอยู่ใน bundle ของเบราว์เซอร์

ขั้นที่ 3: เผยแพร่จาก Server Action

// app/orders/actions.ts
'use server';
import { pusher } from '@/lib/pusher-server';

export async function shipOrder(id: string, socketId?: string) {
  const order = await db.orders.ship(id);
  await pusher.trigger(`private-order-${id}`, 'status', { status: order.status }, { socket_id: socketId });
  return order;
}

Route Handler, webhook จากผู้ให้บริการชำระเงิน และงาน cron ก็เผยแพร่แบบเดียวกัน ต้อง await การเรียกทุกครั้ง เพราะแพลตฟอร์ม serverless อาจหยุดฟังก์ชันทันทีที่ส่งการตอบกลับ

ขั้นที่ 4: ยืนยันสิทธิ์แชนเนลส่วนตัว

// app/api/pusher/auth/route.ts
import { pusher } from '@/lib/pusher-server';
import { auth } from '@/auth';

export async function POST(req: Request) {
  const session = await auth();
  if (!session?.user) return new Response('Unauthorized', { status: 401 });
  const form = await req.formData();
  const socketId = String(form.get('socket_id'));
  const channel = String(form.get('channel_name'));
  const id = channel.replace(/^private-order-/, '');
  if (!(await db.orders.belongsTo(id, session.user.id))) return new Response('Forbidden', { status: 403 });
  return Response.json(pusher.authorizeChannel(socketId, channel));
}

auth() ในที่นี้คือ Auth.js ให้ใช้วิธีอ่าน session ที่แอปของคุณใช้อยู่แล้ว

ขั้นที่ 5: สมัครรับใน Client Component

// lib/pusher-client.ts
import Pusher from 'pusher-js';

let client: Pusher | undefined;

export function getPusher(): Pusher {
  client ??= new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
    cluster: 'ap',
    wsHost: process.env.NEXT_PUBLIC_PUSHER_HOST!,
    forceTLS: true,
    enabledTransports: ['ws', 'wss'],
    channelAuthorization: { endpoint: '/api/pusher/auth', transport: 'ajax' },
  });
  return client;
}
// app/orders/[id]/order-status.tsx
'use client';
import { useEffect, useState } from 'react';
import { getPusher } from '@/lib/pusher-client';

export function OrderStatus({ id, initial }: { id: string; initial: string }) {
  const [status, setStatus] = useState(initial);
  useEffect(() => {
    const name = `private-order-${id}`;
    const channel = getPusher().subscribe(name);
    const onStatus = (data: { status: string }) => setStatus(data.status);
    channel.bind('status', onStatus);
    return () => {
      channel.unbind('status', onStatus);
      getPusher().unsubscribe(name);
    };
  }, [id]);
  return <p>{status}</p>;
}

Server Component เรนเดอร์สถานะปัจจุบันแล้วส่งเป็น initial ส่วน Client Component คอยทำให้ข้อมูลเป็นปัจจุบัน ไคลเอนต์ถูกสร้างใน useEffect จึงไม่มีการเชื่อมต่อระหว่างเรนเดอร์ฝั่งเซิร์ฟเวอร์

เคล็ดลับ

  • ให้เส้นทางยืนยันสิทธิ์และโค้ดที่เผยแพร่อยู่บน Node.js runtime (ค่าเริ่มต้น) เพราะไลบรารี pusher ใช้ crypto ของ Node
  • ถ้าต้องการข้ามผู้ใช้ที่ทำการเปลี่ยนแปลง ให้ส่ง getPusher().connection.socket_id ไปที่ Server Action แบบที่ shipOrder ทำด้านบน
  • ถ้าหลายคอมโพเนนต์ใช้แชนเนลเดียวกัน ให้สมัครรับครั้งเดียวในคอมโพเนนต์แม่ หรือนับจำนวนผู้ใช้ก่อนยกเลิกการสมัคร คอมโพเนนต์หนึ่งที่ถูก unmount จะได้ไม่ตัดตัวอื่นไปด้วย
  • ถ้าอยากใช้ Server Component ซ้ำแทนการส่งข้อมูล ให้เรียก router.refresh() เมื่ออีเวนต์มาถึง แล้วให้ Next.js ดึงผลเรนเดอร์ใหม่
  • เมื่อเข้าสู่ระบบแล้ว คู่มือ Node.js และคู่มือ JavaScript จะมีการตั้งค่าเหล่านี้ที่กรอกค่าของแอปคุณไว้ให้