← 使用场景

在 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 指南会用你的应用数值填好这些设置。