Next.js で Pusher を利用
はじめに
この記事では、Next.js で Pusher を利用してリアルタイム通信する方法を紹介します。
WebSocketとは
WebSocket とは、Web ブラウザと Web サーバー間で双方向なリアルタイム通信するための通信規格です。
WebSocket が登場した背景はこちらです。
- HTTP では 1 つのリクエストに対して 1 つのレスポンスしか返せない。
- サーバからクライアントにデータを送るためには、クライアントからリクエストを送る必要がある。これをポーリングと呼ぶ。
- ポーリングは、サーバに負荷をかけるため、リアルタイム通信には向いていない。
VercelでWebSocketを利用できるか❓
WebSocket を実現するには、常時起動しているサーバが必要です。Vercel は、サーバレスアーキテクチャを採用しているため、サーバの起動時間に制限があり、WebSocket に必要なコネクションを維持できません。よって、Vercel にデプロイした Next.js プロジェクトで WebSocket を利用するには、自分でサーバを立てるか、WebSocket をホストしてくれるマネージドサービスを利用する必要があります。
公式サイトでは代替案として以下のプロバイダーが紹介されてます。
- Ably
- Convex
- Liveblocks
- Partykit
- Pusher
- PubNub
- Firebase Realtime Database
- TalkJS
- SendBird
- Supabase
この記事では Pusher を利用します。
Pusherとは
Pusher は、リアルタイムアプリケーションを簡単に構築できるクラウドベースのサービスです。
Pusher は 2 つの主要なコンポーネントで構成されています。クライアントがリアルタイムの更新を受け取るために接続するフロントエンドの WebSocket 接続と、これらの接続を管理するサーバー側の REST API です。これが Pusher とのインタラクションの大まかな枠組みを形成しています。
クライアント側では、3 つの主要なプロセスが行われます。まず、クライアントは特定のチャネルにサブスクライブします。次は認証ステップで、クライアントがそのチャネルにアクセスする権限があることを確認します。最後に、クライアントはそのチャネル内の特定のイベントにバインドし、リアルタイムデータの更新を受信し処理できるようにします。
Next.js のプロジェクトを作成
作業用に Next.js プロジェクトを作成します。長いので、折り畳んでおきます。
作業用の新規に Next.js プロジェクトを作成します。
プロジェクトの作成
create next-app@latest
でプロジェクトを作成します。
$ pnpm create next-app@latest next-pusher-sample --typescript --eslint --import-alias "@/*" --src-dir --use-pnpm --tailwind --app
$ cd next-pusher-sample
不要な設定を削除し、プロジェクトを初期化します。
stylesの初期化
CSSなどを管理するstylesディレクトリを作成します。globals.css
を移動します。
$ mkdir -p src/styles
$ mv src/app/globals.css src/styles/globals.css
globals.css
の内容を以下のように上書きします。
@tailwind base;
@tailwind components;
@tailwind utilities;
初期ページの初期化
app/page.tsx
を上書きします。
import { type FC } from "react";
const Home: FC = () => {
return (
<div className="">
<div className="text-lg font-bold">Home</div>
<div>
<span className="text-blue-500">Hello</span>
<span className="text-red-500">World</span>
</div>
</div>
);
};
export default Home;
レイアウトの初期化
app/layout.tsx
を上書きします。
import "@/styles/globals.css";
import { type FC } from "react";
type RootLayoutProps = {
children: React.ReactNode;
};
export const metadata = {
title: "Sample",
description: "Generated by create next app",
};
const RootLayout: FC<RootLayoutProps> = (props) => {
return (
<html lang="ja">
<body className="">{props.children}</body>
</html>
);
};
export default RootLayout;
TailwindCSSの設定
TailwindCSSの設定を上書きします。
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
plugins: [],
}
export default config
TypeScriptの設定
TypeScriptの設定を上書きします。
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": ".",
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
スクリプトを追加
型チェックのスクリプトを追加します。
{
"scripts": {
+ "typecheck": "tsc"
},
}
動作確認
型チェックします。
$ pnpm run typecheck
ローカルで動作確認します。
$ pnpm run dev
コミットして作業結果を保存しておきます。
$ git add .
$ git commit -m "作業用のプロジェクトを作成"
Pusher アカウントの作成
Pusher を利用するためには、Pusher アカウントします。
まずサイトにアクセスします。
Signup をクリックして、アカウントを作成します。
今回は、GitHub アカウントでログインします。
これで、Pusher のアカウントが作成されました。
Pusher アプリケーションの作成
Pusher にて今回のプロジェクトで利用するアプリケーションを作成します。
Channels の Get started をクリックします。
Front end は React で、Back end は Node.js を選択します。
Create app をクリックします。
アプリケーションが作成されました。
環境変数の設定
Next.js プロジェクトから利用できるように認証情報を環境変数に設定します。
まず、環境変数ファイルを作成します。
$ touch .env.local
App Keys をクリックします。
Copy をクリックします。
.env.local
に認証情報を貼り付けます。PUSHER_KEY
と PUSHER_CLUSTER
はクライアントとサーバの朗報で利用するためパブリックになっています。
# Pusher Credentials
PUSHER_APP_ID=”YourAppId”
PUSHER_SECRET=”YourSecret”
NEXT_PUBLIC_PUSHER_KEY=”YourPublicKey”
NEXT_PUBLIC_PUSHER_CLUSTER=”YourCluster”
NEXT_PUBLIC_
をつけることで、クライアント側で環境変数を利用できます。
Pusherライブラリのインストール
pusher-js
はクライアントで Pusher チャネルへ接続するのに利用します。
pusher
はサーバで Pusher の API と対話するために利用します。
$ pnpm install pusher pusher-js
Pusherのインスタンス
クライアントとサーバサイドで利用する Pusher のインスタンスを作成します。
libs
フォルダを作成します。
$ mkdir -p src/libs/pusher/client
$ mkdir -p src/libs/pusher/server
$ touch src/libs/pusher/client/index.ts
$ touch src/libs/pusher/server/index.ts
クライアント側のコードを作成します。authEndpoint
は Pusher がプライベート チャネルの認証を要求するサーバー側のルートです。
import PusherClient from "pusher-js";
export const pusherClient = new PusherClient(process.env.NEXT_PUBLIC_PUSHER_KEY as string, {
cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER as string,
// authEndpoint は Pusher がプライベート チャネルの認証を要求するサーバー側のルートです
authEndpoint: "/api/pusher/auth",
});
サーバ側のコードを作成します。
import PusherServer from "pusher";
let pusherInstance: PusherServer | null = null;
export const getPusherInstance = () => {
if (!pusherInstance) {
pusherInstance = new PusherServer({
appId: process.env.PUSHER_APP_ID as string,
key: process.env.NEXT_PUBLIC_PUSHER_KEY as string,
secret: process.env.PUSHER_SECRET as string,
cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER as string,
useTLS: true,
});
}
return pusherInstance;
};
認証ルートの作成
Pusher の認証メカニズムでは、認証されたユーザーのみがプライベートまたはプレゼンスチャンネルにアクセスできます。ユーザーの認証情報を確認し、アクセス権限をサーバ側で処理します。
この API では Pusher チャンネルへのアクセスを許可する前にユーザーセッションと権限を確認します。
$ mkdir -p src/app/api/pusher/auth
$ touch src/app/api/pusher/auth/route.ts
import { getPusherInstance } from "@/libs/pusher/server";
const pusherServer = getPusherInstance();
export async function POST(req: Request) {
console.log("authenticating pusher perms...")
const data = await req.text();
const [socketId, channelName] = data
.split("&")
.map((str) => str.split("=")[1]);
const authResponse = pusherServer.authorizeChannel(socketId, channelName);
return new Response(JSON.stringify(authResponse));
}
注意点として認証する API がない場合、全てのチャンネルがパブリックチャンネルになってしまいます。
APIを作成
メッセージを送信する API を作成します。
$ mkdir -p touch src/app/api/test
$ touch src/app/api/test/route.ts
import { getPusherInstance } from "@/libs/pusher/server";
const pusherServer = getPusherInstance();
export const dynamic = 'force-dynamic' // defaults to auto
export type ReturnDataType = {
message: string;
user: string;
date: Date;
};
export async function POST(req: Request, res: Response) {
const { user, message } = await req.json()
try {
await pusherServer.trigger("private-chat", "evt::test", {
user,
message,
date: new Date(),
} as ReturnDataType);
return Response.json({ message: "Sockets tested" }, { status: 200 });
} catch (error) {
console.error(error);
return Response.json(
{ message: "Failed to test sockets", error: error },
{ status: 500 }
);
}
}
コンポーネント
メッセージを送信するためのコンポーネントをここでは作成します。
接続テスト用のコンポーネントを作成します。
$ mkdir -p src/components
$ touch src/components/MessageList.tsx
"use client";
import { ReturnDataType } from "@/app/api/test/route";
import { pusherClient } from "@/libs/pusher/client";
import { useEffect, useState } from "react";
type MessageListProps = {};
export const MessageList = (props: MessageListProps) => {
const [messages, setMessages] = useState<ReturnDataType[]>([]);
const [inputMessage, setInputMessage] = useState("");
const [username, setUsername] = useState("");
useEffect(() => {
const channel = pusherClient
.subscribe("private-chat")
.bind("evt::test", (data: ReturnDataType) => {
console.log("received_from_pusher", data);
setMessages((prevMessages) => [...prevMessages, data]);
});
return () => {
channel.unbind();
};
}, []);
const handleTestClick = async () => {
if (!inputMessage || !username) return; // Prevent sending empty messages or without username
const body = { message: inputMessage, user: username };
let data = await fetch("/api/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
let json = await data.json();
console.log("handle_test_click_response", json);
setInputMessage(""); // Clear the input field after sending the message
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
handleTestClick();
}
};
return (
<div className="flex flex-col">
<div className="flex">
<input
type="text"
placeholder="ユーザー名"
className="w-[100px] border border-slate-400 rounded-lg px-3 py-2 m-2 text-sm text-slate-800"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
type="text"
placeholder="メッセージ"
className="w-[400px] border border-slate-400 rounded-lg px-3 py-2 m-2 text-sm text-slate-800"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyDown={handleKeyDown}
/>
<button
className={`w-[80px] rounded-lg p-2 m-2 shadow-lg text-sm ${
inputMessage && username
? "bg-slate-800 hover:bg-slate-600 text-white"
: "bg-gray-300 text-gray-500 cursor-not-allowed"
}`}
onClick={handleTestClick}
disabled={!inputMessage || !username}
>
送信
</button>
</div>
<div>
{messages
.slice()
.reverse()
.map((message, index) => (
<div
className="border border-green-400 bg-green-200 rounded-2xl px-4 py-3 m-2 text-slate-800 text-sm"
key={index}
>
<div>
<div className="text-xs text-slate-400">{message.user}</div>
<div className="mt-2 mb-2">{message.message}</div>
<div className="text-xs text-slate-400">
{new Date(message.date).toLocaleString()}
</div>
</div>
</div>
))}
</div>
</div>
);
};
ページを更新
作成したコンポーネントをページに追加します。
import { MessageList } from "@/components/MessageList";
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<MessageList />
</main>
);
}
動作確認
ローカルで動作確認します。
$ pnpm run dev
コミットします。
$ git add .
$ git commit -m "Pusherを利用してリアルタイム通信を行う"
まとめ
この記事では、Next.js で Pusher を利用してリアルタイム通信する方法を紹介しました。
作業リポジトリ
作業リポジトリはこちらです。
参考
Discussion