のこのこかずのこ

10年エンジニアやってるけどいまだになんもわからん

JavaScriptのServer-Sent Eventsを使ってローカルで動作するチャットっぽい画面を作る

やりたいこと

サーバー送信イベント - Web API | MDN

  • Server-Sent Eventsとは、ロングポーリングのための便利なインターフェース。ロングポーリングとは、Webサーバとのネットワーク接続を開き続けて、サーバから必要なときにクライアントにデータを送信できる技術のこと。プッシュ通知とかに使われる。
  • これの使い方を知るために、ローカルで動作する簡単なチャット画面を作りたい。
  • 複数のブラウザやブラウザのタブから入力されたテキストを、リアルタイムで反映させる。
  • クライアントサイド は localhost:8000 で動かす。サーバーサイドはNode.jsで作成し、localhost:8080で動かす。クロスオリジンになるので、レスポンスヘッダに "Access-Control-Allow-Origin": "*" を設定する。(ローカルでしか動かさないので許して…。)

画面は簡単にこんな感じ。LINEみたいにポコポコメッセージが出る。

こちらの本のサンプルコードを大いに参考にさせていただきました🙇

さっそく実装

クライアントサイド(HTML+JavaScript)

new EventSource(url) がとってもキモとなる部分。このEventSourceオブジェクトが、サーバから送信されたイベントを受け取ってくれる。

localChat.html

<html>
  <head>
    <title>SSE Chat</title>
    <style>
      /* CSSは割愛します */
    </style>
  </head>
  <body>
    <input id="input" />
    <script>
      // 初期表示時に名前を入力する
      let name = prompt("表示名を入力してください");
      let input = document.querySelector("#input");
      input.focus();
      // EventSourceを使って新着メッセージの通知を受け取る
      let chat = new EventSource("http://localhost:8080/chat");
      chat.addEventListener("chat", (event) => { // このchatというイベントは、サーバ側で定義したもの
        // データから名前部分とメッセージ部分を取り出す
        let dataName = event.data.substring(0, event.data.indexOf(":"));
        let text = event.data.substring(event.data.indexOf(":") + 1);
        // 要素を作成してデータを追加する
        let div = document.createElement("div");
        div.classList.add("msg");
        if (dataName === name) {
          // 自分が送信したメッセージだったらクラス追加してスタイルを変える
          div.classList.add("me");
        }
       let nameSpan = document.createElement("span");
        nameSpan.classList.add("name");
        nameSpan.append(dataName);
        let textSpan = document.createElement("span");
        textSpan.classList.add("text");
        textSpan.append(text);
        div.append(nameSpan, textSpan);
        input.before(div);
      });
      // inputに入力したら、fetchでサーバにユーザーのメッセージを送信する
      input.addEventListener("change", () => {
        fetch("http://localhost:8080/chat", {
          method: "POST",
          body: `${name}: ${input.value}`,
        }).catch((e) => console.error); // レスポンスは無視。ただしエラーはログに出す。
        input.value = "";
      });
    </script>
  </body>
</html>

クライアントサイドは、 http-server -p 8000 で動かして、ブラウザから http://localhost:8000/localChat.html でアクセスできるようにしました。(サーバで動かさなくても、単にfile:// でアクセスしても動きます。)

サーバーサイド(Node.js)

ソースコード内でサーバを立てているので、Nodeを実行するだけで、http://localhost:8080/chat でクライアントからアクセスできるようになります。

const http = require("http");
const url = require("url");

// イベントを送信するServerResponseオブジェクトの配列
let clients = [];

// サーバサイドは http://localhost:8080/chat でアクセスできるようにする
let server = new http.Server();
server.listen(8080);

// サーバが新しいリクエストを受け取った時の処理
server.on("request", (request, response) => {
  let pathname = url.parse(request.url).pathname;
  if (pathname === "/chat") {
    if (request.method === "GET") {
      // クライアントが新しいEventSourceオブジェクトを生成したとき(またはEventSourceが自動的に再接続されたとき)
      acceptNewClient(request, response);
      return;
    }
    if (request.method === "POST") {
      // ユーザーが新しいメッセージを入力したとき
      broadcastNewMessage(request, response);
      return;
    }
  }
  // それ以外の接続は404エラー
  response.writeHead(404).end();
});

function acceptNewClient(request, response) {
  // レスポンスオブジェクトを記憶する
  clients.push(response);
  // クライアントが接続を終了した場合は、記憶したクライアントの配列から削除する
  request.connection.on("end", () => {
    clients.splice(clients.indexOf(response), 1);
    response.end();
  });
  // ヘッダを設定し、このクライアントだけに最初のチャットイベントを送信する
  response.writeHead(200, {
    "Content-Type": "text/event-stream",
    Connection: "keep-alive",
    "Cache-Control": "no-cache",
    "Access-Control-Allow-Origin": "*", // 本番環境では設定不可
  });
  response.write("event: chat-start\ndata: Connected\n\n"); // クライアント側では特にキャッチしない
  //接続を維持するために、意図的に response.end() を呼び出さない
}

async function broadcastNewMessage(request, response) {
  // ユーザのメッセージを取得する
  request.setEncoding("utf8");
  let body = "";
  for await (let chunk of request) {
    body += chunk;
  }
  // ボディを読み込んだら、空のレスポンスを送信し接続を閉じる
  response
    .writeHead(200, {
      "Access-Control-Allow-Origin": "*", // 本番環境では設定不可
    })
    .end();
  // メッセージをtext/event-stream形式で作成する
  let message = "data: " + body.replace("\n", "\ndata: ");
  // メッセージデータに「chat」イベントを表す接頭辞と終了のための改行2個を付与する
  let event = `event: chat\n${message}\n\n`;
  // 待受中のすべてのクライアントにこのイベントを送信する
  clients.forEach((client) => client.write(event));
}

チャットみたいな非同期処理って、実装が難しそうなイメージがありましたが、けっこう簡単に短いコードで書けました!EventSource様様だ〜〜✨ あくまでもテスト用にやってみただけなので、実運用に耐えれるコードではないと思いますが、ポコポコメッセージが出る感じが実現できて楽しかったです!