Vercel とサーバーレスの Next.js
Server Action から配信し、Client Component で購読。ソケットを関数で抱えません。
Vercel、Netlify、Cloudflare、AWS Lambda で動く Next.js アプリのサーバーコードは、リクエストごとに実行されます。数百ミリ秒しか生きない関数に何千もの WebSocket 接続を開かせておくことはできないので、「ライブ更新を足す」はたいていポーリングか、別に動かすサーバーに行き着きます。Hibira を使えば、Route Handler と Server Action は短い HTTPS 呼び出し 1 回で配信し、ブラウザは関数ではなく Hibira と接続を保ちます。
ユーザーにとっての価値
- 注文、ジョブ、支払いの状態が、データベースで変わった瞬間に画面でも変わります。
- 通知やコメントが、共有リンクから開いたものも含め、開いているすべてのタブに表示されます。
- ページは速いまま。最初の描画はこれまでどおり Server Component から。
使う機能
- Route Handler と Server Action で
pusher(Node.js のサーバーライブラリ)。 - Client Component で
pusher-js。 - セッションを確認する Route Handler が認可するプライベートチャンネル(
private-)。 - 変更したユーザーに二重に届かないようにする送信者の除外(
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.ioNEXT_PUBLIC_ を付けるのはブラウザに送られるキーとホストだけです。シークレットには決して付けません。
ステップ 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 で入れます。server-only を import しておくと、Client Component がこのファイルを読み込んだ時点でビルドが失敗するので、シークレットがブラウザのバンドルに入ることはありません。
ステップ 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 してください。サーバーレス環境では、レスポンスを返した時点で関数が凍結されることがあります。
ステップ 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 です。アプリですでに使っているセッションの読み方に合わせてください。
ステップ 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 ランタイム(デフォルト)のままにしてください。
pusherライブラリは Node のcryptoを使います。 - 変更したユーザーを除外するには、上の
shipOrderのようにgetPusher().connection.socket_idを Server Action に渡します。 - 複数のコンポーネントが同じチャンネルを使うなら、共通の親で 1 回だけ購読するか、利用数を数えてから購読解除してください。1 つがアンマウントされてもほかが切れません。
- データを送る代わりに Server Component を使い回すなら、イベントが届いたときに
router.refresh()を呼んで、Next.js に新しい描画を取らせます。 - サインインすると、Node.js ガイドと JavaScript ガイドにアプリの値を埋めた設定が表示されます。