🐡

Next.js で Pusher を利用

2024/06/14に公開

はじめに

この記事では、Next.js で Pusher を利用してリアルタイム通信する方法を紹介します。

WebSocketとは

WebSocket とは、Web ブラウザと Web サーバー間で双方向なリアルタイム通信するための通信規格です。

WebSocket が登場した背景はこちらです。

  • HTTP では 1 つのリクエストに対して 1 つのレスポンスしか返せない。
  • サーバからクライアントにデータを送るためには、クライアントからリクエストを送る必要がある。これをポーリングと呼ぶ。
  • ポーリングは、サーバに負荷をかけるため、リアルタイム通信には向いていない。

https://www.freshvoice.net/knowledge/word/6323/

VercelでWebSocketを利用できるか❓

WebSocket を実現するには、常時起動しているサーバが必要です。Vercel は、サーバレスアーキテクチャを採用しているため、サーバの起動時間に制限があり、WebSocket に必要なコネクションを維持できません。よって、Vercel にデプロイした Next.js プロジェクトで WebSocket を利用するには、自分でサーバを立てるか、WebSocket をホストしてくれるマネージドサービスを利用する必要があります。

https://vercel.com/guides/do-vercel-serverless-functions-support-websocket-connections

公式サイトでは代替案として以下のプロバイダーが紹介されてます。

  • Ably
  • Convex
  • Liveblocks
  • Partykit
  • Pusher
  • PubNub
  • Firebase Realtime Database
  • TalkJS
  • SendBird
  • Supabase

この記事では Pusher を利用します。

Pusherとは

Pusher は、リアルタイムアプリケーションを簡単に構築できるクラウドベースのサービスです。

Pusher は 2 つの主要なコンポーネントで構成されています。クライアントがリアルタイムの更新を受け取るために接続するフロントエンドの WebSocket 接続と、これらの接続を管理するサーバー側の REST API です。これが Pusher とのインタラクションの大まかな枠組みを形成しています。

クライアント側では、3 つの主要なプロセスが行われます。まず、クライアントは特定のチャネルにサブスクライブします。次は認証ステップで、クライアントがそのチャネルにアクセスする権限があることを確認します。最後に、クライアントはそのチャネル内の特定のイベントにバインドし、リアルタイムデータの更新を受信し処理できるようにします。

https://pusher.com/channels/

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の内容を以下のように上書きします。

src/styles/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

初期ページの初期化

app/page.tsxを上書きします。

src/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を上書きします。

src/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の設定を上書きします。

tailwind.config.ts
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の設定を上書きします。

tsconfig.json
{
  "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"]
}

スクリプトを追加

型チェックのスクリプトを追加します。

package.json
{
  "scripts": {
+   "typecheck": "tsc"
  },
}

動作確認

型チェックします。

$ pnpm run typecheck

ローカルで動作確認します。

$ pnpm run dev

コミットして作業結果を保存しておきます。

$ git add .
$ git commit -m "作業用のプロジェクトを作成"

Pusher アカウントの作成

Pusher を利用するためには、Pusher アカウントします。

まずサイトにアクセスします。

https://pusher.com/

Signup をクリックして、アカウントを作成します。

alt text

今回は、GitHub アカウントでログインします。

alt text

これで、Pusher のアカウントが作成されました。

alt text

Pusher アプリケーションの作成

Pusher にて今回のプロジェクトで利用するアプリケーションを作成します。

Channels の Get started をクリックします。

alt text

Front end は React で、Back end は Node.js を選択します。

alt text

Create app をクリックします。

alt text

アプリケーションが作成されました。

alt text

環境変数の設定

Next.js プロジェクトから利用できるように認証情報を環境変数に設定します。

まず、環境変数ファイルを作成します。

$ touch .env.local

App Keys をクリックします。

alt text

Copy をクリックします。

alt text

.env.local に認証情報を貼り付けます。PUSHER_KEYPUSHER_CLUSTER はクライアントとサーバの朗報で利用するためパブリックになっています。

.env.local
# Pusher Credentials
PUSHER_APP_ID=”YourAppId”
PUSHER_SECRET=”YourSecret”
NEXT_PUBLIC_PUSHER_KEY=”YourPublicKey”
NEXT_PUBLIC_PUSHER_CLUSTER=”YourCluster”

NEXT_PUBLIC_ をつけることで、クライアント側で環境変数を利用できます。

https://vercel.com/docs/projects/environment-variables/system-environment-variables#using-prefixed-framework-environment-variables-locally

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 がプライベート チャネルの認証を要求するサーバー側のルートです。

src/libs/client/index.ts
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",
});

サーバ側のコードを作成します。

src/libs/server/index.ts
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
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
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
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>
  );
};

ページを更新

作成したコンポーネントをページに追加します。

src/app/page.tsx
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

alt text

コミットします。

$ git add .
$ git commit -m "Pusherを利用してリアルタイム通信を行う"

まとめ

この記事では、Next.js で Pusher を利用してリアルタイム通信する方法を紹介しました。

作業リポジトリ

作業リポジトリはこちらです。

https://github.com/hayato94087/next-pusher-sample

参考

https://pusher.com/channels/

https://zenn.dev/miracle_kaasan/articles/6ff3dd02cd3185

https://zenn.dev/chapi/scraps/a80b79fae7ab40

https://zenn.dev/akibe/scraps/5c29de9b2d86e9

https://zenn.dev/cp20/articles/no-log-chat-app

https://blog.stackademic.com/building-real-time-applications-with-pusher-and-next-js-aa4b58a1379b

https://zenn.dev/chapi/scraps/a80b79fae7ab40

Discussion