やりたいこと
- Server-Sent Eventsとは、ロングポーリングのための便利なインターフェース。ロングポーリングとは、Webサーバとのネットワーク接続を開き続けて、サーバから必要なときにクライアントにデータを送信できる技術のこと。プッシュ通知とかに使われる。
- これの使い方を知るために、ローカルで動作する簡単なチャット画面を作りたい。
- 複数のブラウザやブラウザのタブから入力されたテキストを、リアルタイムで反映させる。
- クライアントサイド は localhost:8000 で動かす。サーバーサイドはNode.jsで作成し、localhost:8080で動かす。クロスオリジンになるので、レスポンスヘッダに
"Access-Control-Allow-Origin": "*"
を設定する。(ローカルでしか動かさないので許して…。)
こちらの本のサンプルコードを大いに参考にさせていただきました🙇
さっそく実装
クライアントサイド(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様様だ〜〜✨ あくまでもテスト用にやってみただけなので、実運用に耐えれるコードではないと思いますが、ポコポコメッセージが出る感じが実現できて楽しかったです!