← 使用情境

在 Vercel 與 Serverless 上的 Next.js

從 server action 發布、在 client component 訂閱,socket 不落在你的函式上。

部署在 Vercel、Netlify、Cloudflare 或 AWS Lambda 上的 Next.js 應用程式,伺服器程式碼是每個請求執行一次。只活幾百毫秒的函式無法一直開著上千條 WebSocket 連線,所以「加上即時更新」最後常常變成輪詢,或是另外架一台伺服器來跑。用 Hibira 的話,你的 route handler 與 server action 只要一次簡短的 HTTPS 呼叫就能發布,瀏覽器的連線是接到 Hibira,而不是你的函式。

使用者得到什麼

  • 訂單、工作與付款狀態在資料庫一變,畫面就跟著變。
  • 通知與留言出現在每個開著的分頁裡,包括從分享連結開啟的分頁。
  • 頁面依然快速:第一次渲染還是來自你的 server component。

用到的功能

  • 在 route handler 與 server action 中使用 pusher(Node.js 伺服器函式庫)。
  • 在 client component 中使用 pusher-js。
  • 私有頻道(private-),由檢查你的 session 的 route handler 授權。
  • 排除發送者(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 安裝這些函式庫。只要有 client component 匯入這個檔案,server-only 就會讓建置失敗,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 與排程工作都用同樣方式發布。一定要 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 函式庫會用到 Node 的 crypto。
  • 要跳過做出變更的使用者,就像上面的 shipOrder 一樣,把 getPusher().connection.socket_id 傳給 server action。
  • 如果多個元件共用同一個頻道,請在共同的父元件訂閱一次,或在取消訂閱前計算使用者數量,免得一個元件卸載就切斷其他元件。
  • 想沿用 server component 而不傳送資料的話,事件到達時呼叫 router.refresh(),讓 Next.js 取得新的渲染結果。
  • 登入後,Node.js 指南與 JavaScript 指南會用你的應用程式數值填好這些設定。