TypeScriptでのWebSocket通信解説:サーバー・クライアント構築

TypeScriptでのWebSocket通信解説:サーバー・クライアント構築

はじめに

インターネットが普及し、ウェブアプリケーションは進化を続けています。初期のウェブは静的なコンテンツの表示が中心でしたが、今日ではリアルタイム性の高いインタラクティブなアプリケーションが求められるようになりました。チャットアプリケーション、オンラインゲーム、リアルタイムデータフィード(株価、スポーツスコアなど)、共同編集ツールといったサービスは、サーバーとクライアント間で即座に情報をやり取りする必要があります。

このようなリアルタイム通信を実現するための技術の一つがWebSocketです。従来のHTTPプロトコルに比べて、WebSocketはより効率的で双方向性の高い通信を可能にします。

本記事では、WebSocket通信の基本的な仕組みから、サーバーサイド(Node.js)とクライアントサイド(ブラウザ)の両方でWebSocketアプリケーションを構築する方法を、TypeScriptを用いて詳細に解説します。TypeScriptを使用することで、型の安全性を享受しながら、より堅牢で保守しやすいコードを記述することができます。

この記事で学ぶこと:

  • WebSocketプロトコルの基本原理とHTTPとの違い
  • Node.js環境でのWebSocketサーバー構築(wsライブラリを使用)
  • ブラウザ環境でのWebSocketクライアント構築(標準APIを使用)
  • TypeScriptを用いた型安全なコード記述
  • WebSocketアプリケーション開発における一般的な考慮事項(エラー処理、セキュリティ、スケーラビリティなど)
  • 簡単なチャットアプリケーションの具体的な実装例

さあ、TypeScriptの世界でWebSocket通信の扉を開きましょう。

WebSocketの基礎知識

WebSocketは、単一のTCP接続上で全二重通信チャネルを提供するコンピュータ通信プロトコルです。これは、クライアントとサーバー間の永続的な接続を確立し、どちらの側からでも独立してデータを送信できるようにします。

従来のHTTP通信との比較

WebSocketが登場するまで、ウェブ上でのリアルタイム通信はHTTPプロトコルを応用した様々な技術に頼っていました。

  1. Polling: クライアントが定期的にサーバーに新しいデータがあるか問い合わせる方法です。実装は簡単ですが、データが頻繁に更新されない場合、多くの無駄なリクエストが発生し、サーバーリソースを消費します。リアルタイム性に欠け、レイテンシも大きくなります。
  2. Long Polling: クライアントからのリクエストに対して、サーバーは新しいデータが利用可能になるまで応答を保留し、データが到着次第応答を返します。応答を受け取ったクライアントはすぐに次のリクエストを送信します。Pollingよりは効率的ですが、接続を長時間維持する必要があり、サーバーのリソースを圧迫することがあります。また、接続切断時の再接続ロジックも必要になります。
  3. HTTP Streaming: サーバーはクライアントからの単一のリクエストに対して、接続を閉じずにデータを断片的に(チャンクで)送信し続けます。サーバーからクライアントへの一方的なデータ送信には有効ですが、クライアントからサーバーへのプッシュはできません。

これらの技術はすべて、基本的には「リクエスト/レスポンス」モデルであるHTTPプロトコルの制約を受けます。クライアントが通信を開始する必要があり、サーバーは単独でクライアントにデータを「プッシュ」することが困難でした。

対照的に、WebSocketは以下のような利点を提供します。

  • 全二重通信: クライアントとサーバーは独立して同時にデータを送信できます。
  • 低オーバーヘッド: 一度接続が確立されると、後続のメッセージはHTTPヘッダーのような余分なオーバーヘッドなしに、軽量なフレーム形式で送信されます。これにより、データ転送の効率が向上します。
  • 永続的な接続: 接続が維持されるため、新しいデータを送受信するたびに接続の確立と切断を行う必要がありません。これにより、レイテンシが大幅に削減されます。

WebSocketプロトコルの概要

WebSocket接続は、HTTP/1.1プロトコルを使用して開始されます。クライアントはサーバーに特別な「アップグレード」リクエストを送信し、HTTPプロトコルからWebSocketプロトコルへの切り替えを要求します。このプロセスを「WebSocketハンドシェイク」と呼びます。

WebSocketハンドシェイク:

クライアントからサーバーへのHTTPリクエスト例:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13

サーバーがWebSocketプロトコルへのアップグレードを受け入れる場合、以下のようなHTTPレスポンスを返します。

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9ilmzYLbbgwsPN8=
Sec-WebSocket-Protocol: chat

サーバーが101 Switching Protocolsステータスコードと、適切なヘッダー(Upgrade, Connection, Sec-WebSocket-Acceptなど)を含むレスポンスを返すと、ハンドシェイクは成功し、接続はHTTPからWebSocketに切り替わります。以後は、HTTPではなくWebSocketプロトコルに従ってデータが交換されます。

Sec-WebSocket-KeySec-WebSocket-Acceptヘッダーは、ハンドシェイクがWebSocketプロトコルに特有のものであることを確認し、プロトコルのダウングレード攻撃を防ぐために使用されます。Sec-WebSocket-Keyはクライアントが生成するBase64エンコードされた16バイトのランダムな値です。サーバーはこれに特定の文字列(”258EAFA5-E914-47DA-95CA-C5AB0DC85B11″)を連結し、SHA-1ハッシュを計算し、その結果をBase64エンコードしたものをSec-WebSocket-Acceptとして返します。クライアントは受け取ったSec-WebSocket-Acceptが期待される値と一致するか検証します。

データフレーム

WebSocket接続が確立されると、データは「フレーム」という単位で送受信されます。各フレームには、ペイロードデータに加えて、フレームの種類(テキストデータ、バイナリデータ、制御フレームなどを示すOpcode)やペイロードの長さなどのメタ情報が含まれます。

  • データフレーム: テキストデータ (Opcode 0x1) またはバイナリデータ (Opcode 0x2) を運びます。大きなメッセージは複数のフラグメントフレームに分割されて送られることがあります。
  • 制御フレーム: 接続の状態管理に使用されます。
    • Ping (Opcode 0x9): 相手に生存確認のために送信されます。
    • Pong (Opcode 0xA): Pingフレームへの応答、または単独で接続維持のために送信されます。
    • Close (Opcode 0x8): 接続の切断を開始または完了するために使用されます。クローズコードと理由を含めることができます。

クライアントからサーバーへのデータフレームは、セキュリティのためにフレームの内容がマスクされる必要があります。サーバーからクライアントへのフレームはマスクされません。

WebSocketの状態 (Ready State)

クライアント側のWebSocket接続は以下の4つの状態を取ります。

  • WebSocket.CONNECTING (0): 接続が確立されていないか、確立中です。
  • WebSocket.OPEN (1): 接続が確立されており、通信可能です。
  • WebSocket.CLOSING (2): 切断ハンドシェイク中です。
  • WebSocket.CLOSED (3): 接続が閉じられたか、開けませんでした。

この状態は、クライアントのWebSocketオブジェクトのreadyStateプロパティで確認できます。

主なイベント

WebSocket通信では、接続の状態変化やメッセージの送受信に応じてイベントが発生します。

  • open: 接続が成功し、データ送受信の準備ができたときに発生します。
  • message: データフレームを受信したときに発生します。受信データはイベントオブジェクトのプロパティに含まれます。
  • error: 接続中にエラーが発生したときに発生します。
  • close: 接続が切断されたときに発生します。クローズコードや切断理由などの情報を含みます。

これらのイベントを適切にハンドリングすることで、WebSocketアプリケーションのロジックを構築します。

TypeScript WebSocketサーバー構築

サーバーサイドでWebSocketを扱う場合、多くのプログラミング言語やフレームワークがライブラリを提供しています。Node.js環境では、wsライブラリが軽量かつ高性能な選択肢として広く使われています。ここでは、wsライブラリとTypeScriptを使ってWebSocketサーバーを構築する方法を解説します。

使用するライブラリの選定

Node.jsでWebSocketサーバーを構築するためのライブラリはいくつかありますが、wsはネイティブのWebSocketプロトコル実装に近く、シンプルでパフォーマンスが良いという特徴があります。他の選択肢としては、Socket.IOなどがありますが、これはWebSocketの上位プロトコルであり、自動再接続やフォールバック機構(WebSocketが使えない場合にLong Pollingなどに切り替える)など、よりリッチな機能を提供します。この記事では、純粋なWebSocketプロトコルを扱うためにwsを使用します。

プロジェクトのセットアップ

まず、Node.jsプロジェクトを作成し、必要なパッケージをインストールします。

“`bash

プロジェクトディレクトリを作成

mkdir my-websocket-server
cd my-websocket-server

npmプロジェクトを初期化

npm init -y

ws ライブラリと TypeScript をインストール

npm install ws typescript @types/ws ts-node

tsconfig.json を生成 (必要に応じて設定を調整)

npx tsc –init
“`

tsconfig.json では、targetを適切なNode.jsのバージョンに、modulecommonjsなどに設定します。また、outDirでコンパイル済みのJavaScriptファイル出力先を指定すると便利です。

json
// tsconfig.json
{
"compilerOptions": {
"target": "es2020", // または使用するNode.jsバージョンに合わせる
"module": "commonjs", // Node.js環境ではcommonjsが一般的
"strict": true, // 厳格な型チェックを有効にする
"esModuleInterop": true, // ES ModuleとCommonJS間の相互運用性を確保
"skipLibCheck": true, // ライブラリの型チェックをスキップ (ビルド時間短縮)
"forceConsistentCasingInFileNames": true, // ファイル名の大文字小文字の区別を強制
"outDir": "./dist" // コンパイル済みJSの出力先
},
"include": [
"src/**/*.ts" // ソースファイルの場所を指定
]
}

プロジェクトのソースコードは src ディレクトリに配置することにします。

ws ライブラリの基本

ws ライブラリは、WebSocketServer クラスを提供します。このクラスのインスタンスを作成し、指定したポートでリッスンすることでWebSocketサーバーを起動できます。

“`typescript
// src/server.ts
import { WebSocketServer } from ‘ws’;

// WebSocketサーバーインスタンスを作成
// port オプションでリッスンするポートを指定
const wss = new WebSocketServer({ port: 8080 });

console.log(‘WebSocket server started on port 8080’);

// クライアントからの接続イベントをリッスン
wss.on(‘connection’, (ws) => {
console.log(‘Client connected’);

// クライアントからのメッセージイベントをリッスン
ws.on(‘message’, (message) => {
// 受信したメッセージは Buffer 型で渡されることが多いので、toString() で文字列に変換
console.log(Received message => ${message.toString()});

// 受信したメッセージをクライアントに送り返す (echo)
ws.send(`Server received: ${message.toString()}`);

});

// クライアントとの接続が閉じられたときのイベントをリッスン
ws.on(‘close’, () => {
console.log(‘Client disconnected’);
});

// エラー発生時のイベントをリッスン
ws.on(‘error’, (error) => {
console.error(WebSocket error: ${error});
});

// 接続確立時にクライアントにメッセージを送信
ws.send(‘Hello from server!’);
});

// サーバー自身のエラーイベントをリッスン
wss.on(‘error’, (error) => {
console.error(WebSocket server error: ${error});
});

// サーバーがlistenを開始したときのイベント(listenオプション使用時など)
wss.on(‘listening’, () => {
console.log(‘WebSocket server is listening’);
});
“`

このコードをコンパイルして実行するには、package.jsonにスクリプトを追加すると便利です。

json
// package.json
{
// ...
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "ts-node src/server.ts" // 開発中はts-nodeで直接実行可能
},
// ...
}

開発中は npm run dev で、本番環境では npm run build の後 npm start でサーバーを起動できます。

イベントハンドリングの詳細

  • wss.on('connection', (ws: WebSocket, request: IncomingMessage) => { ... }):
    • 新しいクライアントが接続したときに発生します。
    • イベントハンドラの第一引数 ws は、接続された個々のクライアントを表す WebSocket インスタンスです。このインスタンスを使って、そのクライアントと通信したり、そのクライアントからのイベントをリッスンしたりします。
    • 第二引数 request は、ハンドシェイクに使用されたHTTPリクエストオブジェクトです。ここからクライアントのIPアドレスやヘッダー情報(例: Origin, クッキー)などを取得できます。
  • ws.on('message', (message: RawData, isBinary: boolean) => { ... }):
    • 個々のクライアントからメッセージを受信したときに発生します。
    • message は受信したデータです。デフォルトではNode.jsの Buffer または ArrayBuffer 型です。toString() メソッドで文字列に変換したり、JSON.parse() でオブジェクトに変換したりします。
    • isBinary は、受信したメッセージがバイナリデータ(true)かテキストデータ(false)かを示します。
  • ws.on('close', (code: number, reason: Buffer) => { ... }):
    • 個々のクライアントとの接続が切断されたときに発生します。
    • code はWebSocketクローズコードです。1000 (Normal Closure) が正常終了、それ以外はエラーなどを示します(詳細はWebSocketプロトコルを参照)。
    • reason は切断理由を示す文字列(Buffer型)です。
  • ws.on('error', (error: Error) => { ... }):
    • 個々のクライアントとの通信中にエラーが発生したときに発生します。ネットワークエラーやプロトコル違反などが含まれます。エラーを放置すると、そのクライアントとの接続は自動的に閉じられることが多いです。

接続されたクライアントの管理

WebSocketサーバーの一般的な機能として、接続中のすべてのクライアントにメッセージを送信する「ブロードキャスト」があります。wsWebSocketServer インスタンスは、接続中のクライアントの集合を管理するプロパティを持っています。

“`typescript
// src/server.ts (クライアント管理とブロードキャストの例)
import { WebSocketServer, WebSocket } from ‘ws’;
// IncomingMessage の型定義が必要になる場合がある
import { IncomingMessage } from ‘http’;

const wss = new WebSocketServer({ port: 8080 });

console.log(‘WebSocket server started on port 8080’);

// 接続中のクライアントリストに型を付ける (Set は ws ライブラリが内部で持つ)
interface CustomWebSocket extends WebSocket {
id?: string; // クライアント識別のためのカスタムプロパティ
// … 他に必要なプロパティ
}

let clientIdCounter = 0;

wss.on(‘connection’, (ws: CustomWebSocket, request: IncomingMessage) => {
clientIdCounter++;
ws.id = client_${clientIdCounter}; // 簡易的なIDを割り当て
console.log(Client connected: ${ws.id});

// 現在接続中のクライアント数
console.log(`Total connected clients: ${wss.clients.size}`);

ws.on('message', (message: Buffer, isBinary: boolean) => {
    const messageString = message.toString();
    console.log(`Received message from ${ws.id}: ${messageString}`);

    // 受信したメッセージを他のすべてのクライアントにブロードキャスト
    // wss.clients は Set<WebSocket> 型のコレクション
    wss.clients.forEach((client: WebSocket) => {
        // ws.readyState === WebSocket.OPEN で接続が確立されているクライアントのみに送信
        if (client !== ws && client.readyState === WebSocket.OPEN) {
            // ここで client の型を CustomWebSocket にキャストするか、
            // wss.clients が CustomWebSocket を含む Set であることを保証する方法もある。
            // 今回は単純化のため WebSocket のまま扱う。
            client.send(`[${ws.id}] ${messageString}`);
        }
    });

    // 自分自身にも送信する場合
    // if (ws.readyState === WebSocket.OPEN) {
    //     ws.send(`You sent: ${messageString}`);
    // }
});

ws.on('close', (code: number, reason: Buffer) => {
    console.log(`Client disconnected: ${ws.id} (Code: ${code}, Reason: ${reason.toString()})`);
    // クライアント管理のリストから削除するなどの処理が必要に応じて行われる(wsライブラリが内部で管理)
});

ws.on('error', (error: Error) => {
    console.error(`WebSocket error for ${ws.id}: ${error}`);
    // エラー発生時も close イベントが通常発生する
});

if (ws.readyState === WebSocket.OPEN) {
    ws.send(`Welcome, ${ws.id}!`);
}

});

wss.on(‘listening’, () => {
const address = wss.address();
if (typeof address === ‘string’) {
console.log(Server listening on ${address});
} else {
console.log(Server listening on ${address.address}:${address.port});
}
});

wss.on(‘error’, (error) => {
console.error(WebSocket server error: ${error});
});

// サーバーをクリーンに終了させるためのシグナルハンドリング (オプション)
process.on(‘SIGTERM’, () => {
console.log(‘SIGTERM received, closing server’);
wss.close(() => {
console.log(‘WebSocket server closed’);
process.exit(0);
});
});
“`

wss.clients は接続中の WebSocket インスタンスを格納する Set です。これを使って、特定のクライアントにメッセージを送ったり、全員にブロードキャストしたりできます。メッセージを送信する際は、ws.send() メソッドを使用します。send メソッドは、テキスト(string)またはバイナリデータ(Buffer, ArrayBuffer, DataView など)を受け取ります。送信前にクライアントの readyStateWebSocket.OPEN であることを確認することが重要です。

カスタムプロパティ(例:クライアントID、ユーザー名)を WebSocket インスタンスに追加したい場合は、上記例のようにインターフェース拡張を利用すると型安全になります。

メッセージフォーマット

WebSocketで送受信されるメッセージはテキストまたはバイナリですが、アプリケーションレベルでは通常、構造化されたデータを使用します。JSON形式がよく利用されます。

“`typescript
// メッセージの型を定義
interface ChatMessage {
type: ‘message’ | ‘user_join’ | ‘user_leave’;
payload: any; // メッセージタイプに応じたデータ
}

// 受信処理の例 (JSONパース)
ws.on(‘message’, (message: Buffer, isBinary: boolean) => {
if (isBinary) {
console.log(‘Received binary message, ignoring.’);
return;
}

try {
    const messageString = message.toString();
    const data: ChatMessage = JSON.parse(messageString);

    console.log(`Received structured message:`, data);

    // メッセージタイプによる処理の振り分け
    if (data.type === 'message') {
        const text = data.payload.text; // payload の型も定義するとより安全
        console.log(`Chat message: ${text}`);
        // ブロードキャストなどの処理...
        wss.clients.forEach((client) => {
            if (client !== ws && client.readyState === WebSocket.OPEN) {
                // JSON文字列として送信
                client.send(JSON.stringify({
                    type: 'message',
                    payload: {
                        senderId: (ws as CustomWebSocket).id, // 送信者情報
                        text: text
                    }
                }));
            }
        });

    } else if (data.type === 'user_join') {
        // 新しいユーザー参加の処理...
    }
    // ...他のメッセージタイプ

} catch (error) {
    console.error('Failed to parse message or handle:', error);
    // 無効なメッセージ形式の場合、接続を切断するなど
    ws.close(1007, 'Invalid message format'); // 1007 は Invalid frame payload data
}

});
“`

JSON.parse() はパースエラーが発生する可能性があるため、try...catch ブロックで囲むことが重要です。送信時も JSON.stringify() を使用してオブジェクトを文字列に変換します。

エラーハンドリングとクローズハンドリング

サーバーとクライアント間の接続は、様々な理由で切断される可能性があります。これには、クライアント側の意図的な切断、ネットワークの問題、サーバー側のエラー、プロトコル違反などが含まれます。

  • ws.on('error', ...): このイベントは、特定のクライアント接続でプロトコル違反やその他の通信エラーが発生した場合に発生します。エラー発生後、そのクライアントとの接続は通常自動的に閉じられます。このハンドラ内では、エラーをログに記録したり、必要に応じてリソースをクリーンアップしたりします。エラーをキャッチしないと、Node.jsプロセス全体がクラッシュする可能性があります。
  • ws.on('close', ...): このイベントは、クリーンに接続が閉じられた場合(クライアントまたはサーバーが close フレームを送信)、またはエラーによって接続が強制的に閉じられた場合に発生します。このハンドラ内で、接続が切断されたクライアントに関連するリソース(例:ユーザーセッション、タイマーなど)を解放するクリーンアップ処理を行います。code 引数から切断理由を判断できます。

サーバーインスタンス (wss) 自体にも error イベントがあります。これは、サーバーがポートをリッスンできない場合など、サーバーレベルのエラーが発生したときに発生します。

“`typescript
// エラーハンドリングの強化
wss.on(‘error’, (error) => {
console.error(‘Server-level error:’, error);
// サーバー全体に影響するエラーへの対応
});

wss.on(‘connection’, (ws: CustomWebSocket) => {
// … message, close イベントハンドラ …

ws.on('error', (error) => {
    console.error(`Error on client ${ws.id}:`, error);
    // このエラーハンドラの後、close イベントが発生することが期待される
});

ws.on('close', (code, reason) => {
    console.log(`Client ${ws.id} disconnected with code ${code} and reason: ${reason.toString()}`);
    // クライアントに関連するリソースのクリーンアップ処理
    // 例: ユーザーリストから削除、関連するセッション情報の破棄など
});

});
“`

エラーハンドリングは堅牢なアプリケーションにとって非常に重要です。特に、プロダクション環境では予期せぬエラーが発生する可能性があるため、適切なログ記録と監視を設定することが不可欠です。

SSL/TLSでの安全な通信 (WSS)

本番環境では、WebSocket接続は通常暗号化されるべきです。これは wss:// スキームを使用することで実現され、HTTPSと同様にSSL/TLSプロトコルが基盤となります。

Node.jsで ws を使用してWSSサーバーを構築するには、https モジュールと証明書ファイル(.key.crt または .pem)が必要です。

“`typescript
import { readFileSync } from ‘fs’;
import * as https from ‘https’;
import { WebSocketServer } from ‘ws’;

// 証明書ファイルのパスを指定
// 実際には、Let’s Encryptなどで取得した有効な証明書を使用してください
const privateKey = readFileSync(‘path/to/your/private.key’, ‘utf8’);
const certificate = readFileSync(‘path/to/your/certificate.crt’, ‘utf8’); // または .pem

const credentials = { key: privateKey, cert: certificate };

// HTTPSサーバーを作成
const httpsServer = https.createServer(credentials);

// WebSocketサーバーをHTTPSサーバーにアタッチ
// noServer: true オプションで、ws自身にポートをリッスンさせず、既存のHTTP/Sサーバーを使用するように設定
const wss = new WebSocketServer({ server: httpsServer });

// HTTPSサーバーをリッスン開始
httpsServer.listen(8443, () => {
console.log(‘HTTPS server listening on port 8443’);
console.log(‘WSS server attached’);
});

// wss.on(‘connection’, …) など、以降のロジックは通常のWSサーバーと同様
wss.on(‘connection’, (ws) => {
console.log(‘WSS client connected’);
ws.on(‘message’, (message) => {
console.log(Received: ${message});
ws.send(‘Got your message over WSS!’);
});
ws.on(‘close’, () => console.log(‘WSS client disconnected’));
ws.on(‘error’, (error) => console.error(‘WSS error:’, error));
});

wss.on(‘error’, (error) => {
console.error(‘WSS server error:’, error);
});
“`

この方法では、まずHTTPSサーバーを作成し、そのサーバーインスタンスを WebSocketServer コンストラクタの server オプションに渡します。クライアントは wss://yourserver.com:8443 のようなURLで接続することになります。ローカルでのテスト目的であれば、自己署名証明書を使用することも可能ですが、ブラウザからは警告が表示されます。

express などのフレームワークとの連携

多くの場合、WebSocketサーバーは既存のWebサーバー(例: Expressで構築されたREST APIサーバー)と共存させる必要があります。ws ライブラリは、既存のHTTP/Sサーバーにアタッチして動作させることが可能です。

“`typescript
import express from ‘express’;
import * as http from ‘http’; // HTTPサーバーの場合
import { WebSocketServer } from ‘ws’;

const app = express();
const httpServer = http.createServer(app); // Expressアプリを処理するHTTPサーバーを作成

// 静的ファイルの提供など、Expressのルーティングを設定
app.get(‘/’, (req, res) => {
res.send(‘

WebSocket Example

‘);
});

// … Expressの他のルーティング …

// WebSocketサーバーを既存のHTTPサーバーにアタッチ
const wss = new WebSocketServer({ server: httpServer });

wss.on(‘connection’, (ws) => {
console.log(‘Client connected via shared server’);
ws.on(‘message’, (message) => {
console.log(Received: ${message});
ws.send(‘Hello from the shared server!’);
});
ws.on(‘close’, () => console.log(‘Client disconnected’));
ws.on(‘error’, (error) => console.error(‘WebSocket error:’, error));
});

const PORT = process.env.PORT || 3000;
httpServer.listen(PORT, () => {
console.log(HTTP and WebSocket server listening on port ${PORT});
});

wss.on(‘error’, (error) => {
console.error(‘WebSocket server error:’, error);
});
“`

この構成では、Expressが一般的なHTTPリクエスト(静的ファイル、REST APIエンドポイントなど)を処理し、WebSocketのハンドシェイク要求が来た場合は ws ライブラリがそれを引き継いでWebSocket接続を確立します。同じポート番号でHTTPとWebSocketの両方を提供できるため、デプロイや管理が容易になります。

TypeScript WebSocketクライアント構築

クライアントサイド、特にブラウザ環境では、JavaScript標準の WebSocket APIが提供されています。TypeScriptを使うことで、このAPIを型安全に利用できます。

ブラウザ環境での WebSocket API

ほとんどのモダンブラウザは WebSocket インターフェースをグローバルオブジェクトとして提供しています。これを使うことで、サーバーとのWebSocket接続を確立できます。

“`typescript
// クライアントサイドのコード例 (TypeScript)
// 例: public/index.ts と想定

// WebSocketサーバーのURLを指定
// WS: ws://localhost:8080 (開発中の非暗号化接続)
// WSS: wss://yourserver.com:8443 (本番環境での暗号化接続)
const websocketUrl = ws://localhost:8080;

let websocket: WebSocket | null = null; // WebSocketインスタンスを保持する変数

// 接続を確立する関数
function connectWebSocket(): void {
// 既存の接続があれば閉じる (再接続時などに)
if (websocket && websocket.readyState !== WebSocket.CLOSED) {
console.log(‘Closing existing connection…’);
websocket.close();
}

console.log(`Attempting to connect to ${websocketUrl}`);
// WebSocketインスタンスを作成
websocket = new WebSocket(websocketUrl);

// 接続成功時のイベントハンドラ
websocket.onopen = (event: Event) => {
    console.log('WebSocket connection opened:', event);
    // 接続成功後にサーバーにメッセージを送信
    if (websocket && websocket.readyState === WebSocket.OPEN) {
         websocket.send('Hello from client!');
    }
};

// メッセージ受信時のイベントハンドラ
websocket.onmessage = (event: MessageEvent) => {
    console.log('Message received:', event.data);
    // event.data は受信したデータ。テキストまたは Blob になりうる。
    if (typeof event.data === 'string') {
        // テキストメッセージの場合
        console.log('Received text message:', event.data);
        displayMessage(event.data, 'server'); // 受信メッセージを画面に表示する関数 (後述)
    } else if (event.data instanceof Blob) {
        // バイナリメッセージ (Blob) の場合
        console.log('Received binary message (Blob)');
        // Blob の処理... 例: FileReader を使うなど
        const reader = new FileReader();
        reader.onload = () => {
             console.log('Binary data as ArrayBuffer:', reader.result);
             // ArrayBuffer の処理...
        };
        reader.readAsArrayBuffer(event.data);
    }
};

// エラー発生時のイベントハンドラ
websocket.onerror = (event: Event) => {
    console.error('WebSocket error observed:', event);
    // エラー発生後、通常 close イベントも発生する
};

// 接続切断時のイベントハンドラ
websocket.onclose = (event: CloseEvent) => {
    console.log('WebSocket connection closed:', event.code, event.reason);
    // 切断理由 (event.code) に応じた処理
    if (event.code === 1000) {
        console.log('Connection closed normally.');
    } else {
        console.log('Connection closed with error or abnormal closure. Reconnecting...');
        // エラーなどによる予期せぬ切断の場合、再接続を試みる
        // 実際にはExponential Backoffなどの再接続戦略を実装することが多い
        setTimeout(connectWebSocket, 5000); // 5秒後に再接続を試みる (簡易実装)
    }
    websocket = null; // インスタンスをクリア
};

}

// ページロード時に接続を開始
window.addEventListener(‘load’, connectWebSocket);

// メッセージを送信する関数 (UIから呼び出される想定)
function sendMessage(message: string): void {
if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(message);
console.log(‘Message sent:’, message);
displayMessage(message, ‘client’); // 送信メッセージを画面に表示 (後述)
} else {
console.warn(‘WebSocket is not connected. Cannot send message.’);
displayMessage(‘Error: Not connected. Cannot send message.’, ‘system’);
}
}

// 受信/送信メッセージを画面に表示する簡易関数
function displayMessage(message: string, sender: ‘server’ | ‘client’ | ‘system’): void {
const messagesDiv = document.getElementById(‘messages’);
if (messagesDiv) {
const messageElement = document.createElement(‘p’);
messageElement.textContent = [${sender}] ${message};
messagesDiv.appendChild(messageElement);
// スクロールを一番下にする
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
}

// UIからのメッセージ送信をトリガーするイベントリスナーの例
// HTML側で





“`

このHTMLファイルとTypeScriptコード (public/index.ts) を用意し、ParcelやWebpackなどのモジュールバンドラを使ってビルドすることで、ブラウザで実行可能なJavaScriptファイルを生成できます。

例 (Parcel):

“`bash

Parcel をインストール

npm install –save-dev parcel

package.json にスクリプトを追加

“start-client”: “parcel public/index.html”

クライアントを起動 (自動的にバンドル、ローカルサーバー起動)

npm run start-client
“`

WebSocket インスタンスの作成とURL

new WebSocket(url, protocols) コンストラクタで接続を作成します。
* url: 接続先のWebSocketサーバーのURLです。ws:// または wss:// スキームで始まります。ポート番号は省略可能(デフォルトはWSが80、WSSが443)。
* protocols (オプション): サポートするサブプロトコル名の配列または単一の文字列です。サーバーはこれらの候補の中から一つを選択し、Sec-WebSocket-Protocol ヘッダーで返します。クライアントの protocol プロパティで選択されたプロトコルを確認できます。

イベントハンドリングと状態確認

クライアントの WebSocket インスタンスも、サーバーと同様にイベントベースで動作します。

  • onopen: 接続が正常に確立されたときに呼び出されます。
  • onmessage: サーバーからメッセージを受信したときに呼び出されます。イベントオブジェクト (MessageEvent) の data プロパティにメッセージ内容が含まれます。
  • onerror: 接続中にエラーが発生したときに呼び出されます。イベントオブジェクト (Event) から詳細情報を取得できますが、エラーの種類によっては情報が限定されることがあります。
  • onclose: 接続が閉じられたときに呼び出されます。イベントオブジェクト (CloseEvent) は code (クローズコード) と reason (切断理由) プロパティを持ちます。

websocket.readyState プロパティは、前述のWebSocketの状態(CONNECTING, OPEN, CLOSING, CLOSED)を示す数値です。メッセージを送信する前に readyState === WebSocket.OPEN であることを確認することが重要です。

メッセージの送受信

メッセージの送信は websocket.send(data) メソッドを使用します。data には文字列、Blob、ArrayBufferなどを指定できます。サーバーと同様、構造化データにはJSON文字列を使うのが一般的です。

受信したメッセージは onmessage イベントハンドラの event.data から取得できます。その型はテキストメッセージの場合は string、バイナリメッセージの場合は Blob となります。バイナリデータを ArrayBuffer として扱いたい場合は、Blob から変換する必要があります(例:FileReader を使用)。

クライアントサイドの型定義

サーバーサイドと同様に、クライアントサイドでも送受信するメッセージの型を定義することで、コードの安全性を高めることができます。

“`typescript
// shared/messageTypes.ts (サーバーとクライアントで共有すると良い)
export interface BaseMessage {
type: string;
}

export interface ChatMessagePayload {
senderId: string;
text: string;
}

export interface ChatMessage extends BaseMessage {
type: ‘message’;
payload: ChatMessagePayload;
}

export interface UserJoinMessagePayload {
userId: string;
// … 他のユーザー情報
}

export interface UserJoinMessage extends BaseMessage {
type: ‘user_join’;
payload: UserJoinMessagePayload;
}

// アプリケーションで扱うすべてのメッセージ型を結合
export type AppMessage = ChatMessage | UserJoinMessage / | …他のメッセージ型 /;
“`

クライアントコードでは、受信したメッセージをこれらの型にキャストして扱います。ただし、受信データはネットワーク経由で送られてくるため、その構造が期待通りのものであるとは限りません。したがって、受信メッセージをパースし、その構造と型を検証するロジックが必要です。zodio-ts のようなバリデーションライブラリを使用すると、実行時の型検証を安全に行えます。

“`typescript
// クライアントでのメッセージ受信処理 (型検証あり)
import { z } from ‘zod’; // 例として zod を使用

// zod を使ってメッセージペイロードのスキーマを定義
const ChatMessagePayloadSchema = z.object({
senderId: z.string(),
text: z.string(),
});

const ChatMessageSchema = z.object({
type: z.literal(‘message’),
payload: ChatMessagePayloadSchema,
});

// 他のメッセージ型も定義…

const AppMessageSchema = z.union([
ChatMessageSchema,
// …他のメッセージスキーマ
]);

websocket.onmessage = (event: MessageEvent) => {
if (typeof event.data !== ‘string’) {
console.warn(‘Received non-string message, ignoring.’);
return;
}

try {
    const parsedData = JSON.parse(event.data);
    // zod でパースと検証を行う
    const message = AppMessageSchema.parse(parsedData);

    console.log('Received valid message:', message);

    // 検証済みのメッセージ型 (message: AppMessage) を安全に使用
    if (message.type === 'message') {
        console.log(`Chat message from ${message.payload.senderId}: ${message.payload.text}`);
        displayMessage(message.payload.text, message.payload.senderId); // 例: senderId を表示に使用
    } else if (message.type === 'user_join') {
        console.log(`User joined: ${message.payload.userId}`);
        displayMessage(`User ${message.payload.userId} joined.`, 'system');
    }
    // ...他のメッセージタイプ

} catch (error) {
    if (error instanceof z.ZodError) {
        console.error('Received message validation error:', error.errors);
    } else {
        console.error('Failed to parse or process message:', error);
    }
    // 無効なメッセージ形式の場合、エラー表示などを行う
}

};
“`

zod を使用すると、受信したJSONデータが事前に定義したスキーマ(型)に一致するかを検証できます。検証に成功すれば、そのデータは確実に定義された型であると保証されるため、以降の処理を安全に進められます。

再接続ロジック

クライアント側のWebSocket接続は、サーバーの再起動、ネットワークの問題、アイドルタイムアウトなど、様々な理由で予期せず切断されることがあります。ユーザー体験を向上させるためには、自動的な再接続ロジックを実装することが重要です。

基本的な再接続は、onclose イベント内で一定時間待ってから connectWebSocket() 関数を再度呼び出すことで実現できます(上記の例に簡易的に実装済み)。より高度な再接続戦略としては、切断回数に応じて待機時間を指数関数的に長くする Exponential Backoff がよく用いられます。これにより、サーバーに過度な負荷をかけずに再接続を試みることができます。

“`typescript
// Exponential Backoff を考慮した再接続ロジックの例

const websocketUrl = ws://localhost:8080;
let websocket: WebSocket | null = null;
let reconnectAttempts = 0;
const maxReconnectAttempts = 10; // 最大再接続試行回数
const initialReconnectDelay = 1000; // 最初の再接続遅延 (ms)

function connectWebSocket(): void {
if (websocket && websocket.readyState !== WebSocket.CLOSED) {
console.log(‘Closing existing connection before attempting reconnect.’);
websocket.close();
}

if (reconnectAttempts >= maxReconnectAttempts) {
    console.error('Max reconnect attempts reached. Giving up.');
    displayMessage('Connection failed after multiple attempts.', 'system');
    return;
}

const delay = initialReconnectDelay * Math.pow(2, reconnectAttempts);
console.log(`Attempting to connect to ${websocketUrl} (Attempt ${reconnectAttempts + 1}/${maxReconnectAttempts}, delay ${delay}ms)`);

setTimeout(() => {
    websocket = new WebSocket(websocketUrl);

    websocket.onopen = (event: Event) => {
        console.log('WebSocket connection opened:', event);
        reconnectAttempts = 0; // 接続成功したらリトライ回数をリセット
        if (websocket && websocket.readyState === WebSocket.OPEN) {
             websocket.send('Hello again from client!'); // 再接続成功時のメッセージ
        }
        displayMessage('Connection established.', 'system');
    };

    websocket.onmessage = (event: MessageEvent) => {
        // ... メッセージ受信処理 ...
    };

    websocket.onerror = (event: Event) => {
        console.error('WebSocket error observed:', event);
        // onclose イベントで再接続処理を行うので、ここではエラーログのみ
    };

    websocket.onclose = (event: CloseEvent) => {
        console.log('WebSocket connection closed:', event.code, event.reason);
        websocket = null; // インスタンスをクリア

        if (event.code === 1000) {
            console.log('Connection closed normally.');
             displayMessage('Connection closed.', 'system');
        } else {
            console.log('Connection closed with error or abnormal closure. Retrying...');
            reconnectAttempts++;
            connectWebSocket(); // 再接続を試みる
             displayMessage(`Connection lost. Retrying... (Attempt ${reconnectAttempts})`, 'system');
        }
    };
}, delay);

}

window.addEventListener(‘load’, connectWebSocket);

// … sendMessage, displayMessage 関数など …
“`

共通の考慮事項とベストプラクティス

サーバーとクライアントの両方を構築する際に考慮すべき共通の事項や、より堅牢なWebSocketアプリケーションを構築するためのベストプラクティスについて説明します。

メッセージフォーマットとプロトコル設計

アプリケーションレベルで送受信するメッセージのフォーマットを明確に定義することは非常に重要です。前述のようにJSONが一般的ですが、バイナリプロトコル(Protocol Buffers, MessagePackなど)はデータ量を削減し、パフォーマンスを向上させるのに役立ちます。

どのようなフォーマットを選択するにしても、メッセージには「タイプ」フィールドを含め、そのタイプに基づいて適切な処理を振り分けるように設計するのが良いプラクティスです。これにより、新しいメッセージタイプを追加しやすくなり、プロトコルの拡張性が向上します。

“`typescript
// メッセージ型の例 (共通定義ファイル)
interface Message {
type: string; // メッセージの種類を識別
payload: any; // メッセージの種類に応じたデータ
// オプション: timestamp, senderId, requestId など
}

// チャットメッセージ
interface ChatMessage extends Message {
type: ‘chat’;
payload: {
text: string;
userId: string;
timestamp: number; // Unixタイムスタンプ
};
}

// ユーザー参加通知
interface UserJoinMessage extends Message {
type: ‘user_join’;
payload: {
userId: string;
username: string;
};
}

// エラー通知
interface ErrorMessage extends Message {
type: ‘error’;
payload: {
code: number; // アプリケーション定義のエラーコード
message: string; // エラーの説明
requestId?: string; // リクエストと紐付けるためのID
};
}

// 送信リクエストに対する応答 (例: ログインリクエストへの応答)
interface ResponseMessage extends Message {
type: ‘response’;
payload: {
requestId: string; // 対応するリクエストのID
status: ‘success’ | ‘error’;
data?: any; // 成功時のデータ
error?: { // エラー時の詳細
code: number;
message: string;
};
};
}

// すべての可能なメッセージ型
type AppMessage = ChatMessage | UserJoinMessage | ErrorMessage | ResponseMessage | / …他のメッセージ型 /;

// サーバーまたはクライアントでの受信処理ロジック
function handleReceivedMessage(message: AppMessage): void {
switch (message.type) {
case ‘chat’:
console.log([${message.payload.userId}] ${message.payload.text});
// UIに表示するなど
break;
case ‘user_join’:
console.log(${message.payload.username} joined.);
// ユーザーリストを更新するなど
break;
case ‘error’:
console.error(Application Error (${message.payload.code}): ${message.payload.message});
// ユーザーに通知するなど
break;
case ‘response’:
console.log(Response to request ${message.payload.requestId}:, message.payload);
// 対応するリクエスト元の処理を続行
break;
default:
// 未知のメッセージタイプ
console.warn(Received unknown message type: ${message.type});
// ログ記録、または接続を切断
// ws.close(1002, ‘Protocol Error’); // 1002 は Protocol Error
}
}
“`

このようにメッセージタイプに基づいた処理を行うことで、コードの見通しが良くなり、拡張性が高まります。

エラー処理戦略

WebSocket接続におけるエラーは、プロトコルレベルのエラー(無効なフレーム、大きなペイロードなど)とアプリケーションレベルのエラー(無効なデータ形式、認証失敗、ビジネスロジックエラーなど)に分けられます。

  • プロトコルエラー: ws ライブラリやブラウザの WebSocket APIが自動的に検出して error イベントを発火させ、通常接続を閉じます。サーバーサイドでは、これらのエラーによって特定のクライアント接続が切断されても、サーバープロセス自体は維持されるように設計することが重要です。クライアントサイドでは、onerroronclose イベントを適切にハンドリングして、ユーザーに通知したり再接続を試みたりします。
  • アプリケーションエラー: これは開発者が独自に検出・処理する必要があります。不正な入力、認証・認可の失敗、存在しないリソースへのアクセスなど、様々な要因で発生します。アプリケーションエラーが発生した場合、関連するクライアントにエラーメッセージを送信することが一般的です(前述の ErrorMessage の例のように)。重大なアプリケーションエラーでそのクライアントとの通信が続行できない場合は、クローズコード1008(Policy Violation)などを使用して接続を閉じることも検討します。

セキュリティ

WebSocketは全二重通信チャネルを提供するため、セキュリティには特別な注意が必要です。

  • SSL/TLS (WSS): 機密性の高いデータや認証情報を含む通信を行う場合は、必ず wss:// を使用して接続を暗号化します。自己署名証明書ではなく、認証局によって発行された有効な証明書を使用してください。
  • オリジンチェック: サーバー側では、WebSocket接続要求の Origin ヘッダーを検証し、許可されたオリジン(ウェブサイトのドメインなど)からの接続のみを受け入れるべきです。これはクロスサイト WebSocket ハイジャック攻撃 (CSWSH) を防ぐのに役立ちます。
  • 認証と認可: WebSocket接続が確立された後、クライアントが誰であるか(認証)およびそのクライアントが何を行うことが許可されているか(認可)を確認する必要があります。これは、WebSocketハンドシェイク時にHTTPヘッダー(例:Cookie、Authorizationヘッダー)を利用したり、接続確立後に最初のメッセージとして認証情報(例:トークン)を送信させたりすることで実現できます。認証・認可されていないクライアントからの不正なメッセージは拒否し、必要に応じて接続を切断します。
  • 入力検証: クライアントから受信したメッセージの内容は、サーバーサイドで常に厳格に検証する必要があります。期待される型、構造、値の範囲などをチェックし、無効なデータは拒否します。これは、SQLインジェクションやクロスサイトスクリプティング (XSS) といった一般的なウェブ脆弱性と同様の攻撃を防ぐために不可欠です。前述の zod を使った検証などが有効です。
  • DoS対策: 悪意のあるクライアントが大量の接続要求やメッセージを送信してサーバーを過負荷にすることを防ぐために、レート制限や接続数制限を導入します。また、巨大なメッセージペイロードや無限ループを引き起こすようなデータ構造の送信を防ぐための対策も必要です。
  • PING/PONGフレーム: WebSocketプロトコルには、アイドル状態の接続を維持するためのPing/Pongフレームがあります。サーバーは定期的にクライアントにPingフレームを送信し、クライアントはPongフレームで応答します。一定時間Pong応答がないクライアントは、タイムアウトとみなして接続を切断します。これにより、切断されたままのリソースを消費する「ゾンビ接続」を防ぐことができます。ws ライブラリは ping オプションや、clientTracking が有効な場合のアイドルタイムアウト機能を提供します。

スケーラビリティ

多数のクライアントを同時に捌くWebSocketサーバーは、スケーラビリティが課題となります。

  • 単一サーバーの限界: Node.jsはシングルスレッドで動作するため、CPUバウンドな処理が多いとパフォーマンスボトルネックになります。WebSocketハンドリング自体はI/Oバウンドな処理ですが、メッセージ処理ロジックが複雑になると影響が出ます。Node.jsクラスターモジュールやPM2などを使用して複数のNode.jsプロセスを起動し、CPUコアをフル活用できます。
  • 複数サーバー構成: アプリケーションが成長し、単一サーバーでは対応できなくなった場合、複数のWebSocketサーバーインスタンスを立ててロードバランサーの背後に配置するのが一般的です。しかし、クライアントがどのサーバーに接続しても他のクライアントと通信できるようにするためには、サーバー間でメッセージを共有する仕組みが必要です。
  • Pub/Subパターン: この課題を解決するために、Redis Pub/SubやKafka、RabbitMQといったメッセージキューシステムを利用したPub/Sub (Publish/Subscribe) パターンがよく使われます。各WebSocketサーバーは特定のトピック(例:チャットルーム、ユーザーID)を購読し、クライアントからメッセージを受信すると、そのメッセージを対応するトピックに発行します。同じトピックを購読している他のすべてのサーバーインスタンスはメッセージを受信し、自分に接続している関連クライアントにブロードキャストします。これにより、クライアントはどのサーバーに接続していても、同じ論理的なメッセージチャネルに参加できます。

“`typescript
// Pub/Sub を使用したスケーラブルなチャットサーバーの概念コード例 (Redis Pub/Subを使用)
import { WebSocketServer, WebSocket } from ‘ws’;
import { createClient } from ‘redis’; // redis クライアントライブラリを使用

// Redis クライアントを作成
const publisher = createClient();
const subscriber = createClient();

publisher.on(‘error’, (err) => console.error(‘Redis Publisher Error:’, err));
subscriber.on(‘error’, (err) => console.error(‘Redis Subscriber Error:’, err));

// Redis に接続
Promise.all([publisher.connect(), subscriber.connect()]).then(() => {
console.log(‘Connected to Redis’);

const wss = new WebSocketServer({ port: 8080 });
console.log('WebSocket server started on port 8080');

const CHANNEL = 'chat_messages'; // Redis の Pub/Sub チャンネル名

// Redis チャンネルを購読
subscriber.subscribe(CHANNEL, (message, channel) => {
    console.log(`Received message from Redis channel ${channel}: ${message}`);
    // Redis から受信したメッセージを、自分に接続している全てのクライアントにブロードキャスト
    wss.clients.forEach((client) => {
        if (client.readyState === WebSocket.OPEN) {
            client.send(message); // Redis から受信したメッセージをそのまま送信
        }
    });
});

wss.on('connection', (ws) => {
    console.log('Client connected');

    ws.on('message', async (message: Buffer) => {
        const messageString = message.toString();
        console.log(`Received message from client: ${messageString}`);

        // クライアントから受信したメッセージを Redis チャンネルに発行
        try {
            await publisher.publish(CHANNEL, messageString);
            console.log(`Published message to Redis channel ${CHANNEL}`);
        } catch (err) {
            console.error('Failed to publish message to Redis:', err);
            // クライアントにエラーを通知
            if (ws.readyState === WebSocket.OPEN) {
                 ws.send(JSON.stringify({ type: 'error', payload: { code: 500, message: 'Failed to send message' } }));
            }
        }
    });

    ws.on('close', () => console.log('Client disconnected'));
    ws.on('error', (error) => console.error('WebSocket error:', error));

    if (ws.readyState === WebSocket.OPEN) {
        ws.send('Welcome!');
    }
});

wss.on('error', (error) => console.error('WebSocket server error:', error));

}).catch(err => {
console.error(‘Failed to connect to Redis:’, err);
process.exit(1); // Redis 接続失敗時はサーバーを起動しない
});

// サーバー終了時に Redis 接続を閉じる (オプション)
process.on(‘SIGINT’, async () => {
console.log(‘Closing Redis connections and server…’);
await publisher.quit();
await subscriber.quit();
wss.close(() => {
console.log(‘WebSocket server closed.’);
process.exit(0);
});
});
“`

この構造では、複数のNode.jsサーバーインスタンスが同一のRedisサーバーに接続し、メッセージの送受信を行います。これにより、どのサーバーに接続しているクライアントから送信されたメッセージも、他のすべてのサーバーを経由して適切なクライアントに届けられます。

実践例:簡単なチャットアプリケーション

前述のサーバーとクライアントのコードを組み合わせ、簡単なチャットアプリケーションを作成してみましょう。

1. サーバーサイド (src/server.ts)

以前のコードに、ユーザー管理(簡易的な匿名ユーザー)とチャットメッセージのブロードキャスト機能を追加します。メッセージフォーマットはJSONを使用します。

“`typescript
// src/server.ts
import { WebSocketServer, WebSocket } from ‘ws’;
import { IncomingMessage } from ‘http’;
import { v4 as uuidv4 } from ‘uuid’; // npm install uuid @types/uuid でインストール

// メッセージの型定義 (共有ファイルを想定)
interface BaseMessage {
type: string;
}

interface ChatMessagePayload {
userId: string;
username: string; // ユーザー名を追加
text: string;
timestamp: number;
}

interface ChatMessage extends BaseMessage {
type: ‘chat’;
payload: ChatMessagePayload;
}

interface UserJoinMessagePayload {
userId: string;
username: string;
}

interface UserJoinMessage extends BaseMessage {
type: ‘user_join’;
payload: UserJoinMessagePayload;
}

interface UserLeaveMessagePayload {
userId: string;
username: string;
}

interface UserLeaveMessage extends BaseMessage {
type: ‘user_leave’;
payload: UserLeaveMessagePayload;
}

interface UserListMessagePayload {
users: { userId: string; username: string }[];
}

interface UserListMessage extends BaseMessage {
type: ‘user_list’;
payload: UserListMessagePayload;
}

interface ErrorMessagePayload {
code: number;
message: string;
}

interface ErrorMessage extends BaseMessage {
type: ‘error’;
payload: ErrorMessagePayload;
}

type AppMessage = ChatMessage | UserJoinMessage | UserLeaveMessage | UserListMessage | ErrorMessage;

// クライアント接続にカスタムプロパティを追加
interface CustomWebSocket extends WebSocket {
userId: string;
username: string;
}

const wss = new WebSocketServer({ port: 8080 });

console.log(‘WebSocket server started on port 8080’);

// 接続中のユーザーリストを管理
const users: { [userId: string]: { username: string; ws: CustomWebSocket } } = {};

function broadcast(message: AppMessage, senderWs?: CustomWebSocket): void {
const messageString = JSON.stringify(message);
wss.clients.forEach((client) => {
// senderWs が指定されている場合は、送信者自身には送らない (オプショナル)
if (client.readyState === WebSocket.OPEN && (!senderWs || client !== senderWs)) {
client.send(messageString);
}
});
}

function sendToClient(ws: CustomWebSocket, message: AppMessage): void {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}

function updateUserList(): void {
const userListPayload: UserListMessagePayload = {
users: Object.values(users).map(u => ({ userId: u.userId, username: u.username }))
};
const userListMessage: UserListMessage = { type: ‘user_list’, payload: userListPayload };
broadcast(userListMessage); // 全員にユーザーリストをブロードキャスト
}

wss.on(‘connection’, (ws: CustomWebSocket, request: IncomingMessage) => {
// 簡易的なユーザーIDと匿名ユーザー名を生成
ws.userId = uuidv4();
ws.username = Guest${Object.keys(users).length + 1};

users[ws.userId] = { username: ws.username, ws: ws };
console.log(`User connected: ${ws.username} (${ws.userId}). Total users: ${Object.keys(users).length}`);

// 新規参加者へのウェルカムメッセージ
sendToClient(ws, {
    type: 'chat',
    payload: {
        userId: 'system',
        username: 'System',
        text: `Welcome, ${ws.username}!`,
        timestamp: Date.now()
    }
});

// 他のクライアントに新規参加を通知
broadcast({
    type: 'user_join',
    payload: { userId: ws.userId, username: ws.username }
}, ws); // 自分自身には通知しない

// 新規参加者に現在のユーザーリストを送信
updateUserList();


ws.on('message', (message: Buffer, isBinary: boolean) => {
    if (isBinary) {
        console.log(`Received binary message from ${ws.username}, ignoring.`);
        sendToClient(ws, {
             type: 'error',
             payload: { code: 400, message: 'Binary messages not supported.' }
        });
        return;
    }

    try {
        const messageString = message.toString();
        const parsedMessage: BaseMessage = JSON.parse(messageString); // まずBaseMessageとしてパース

        switch (parsedMessage.type) {
            case 'chat':
                // chatメッセージのペイロードを検証 (zodなどで検証するとより安全)
                const chatPayload = (parsedMessage as ChatMessage).payload;
                if (typeof chatPayload.text !== 'string' || chatPayload.text.trim().length === 0) {
                     sendToClient(ws, {
                         type: 'error',
                         payload: { code: 400, message: 'Invalid chat message format or empty text.' }
                     });
                     return;
                }

                const chatMessage: ChatMessage = {
                    type: 'chat',
                    payload: {
                        userId: ws.userId,
                        username: ws.username,
                        text: chatPayload.text.trim(),
                        timestamp: Date.now()
                    }
                };
                console.log(`Broadcasting chat message from ${ws.username}: ${chatMessage.payload.text}`);
                broadcast(chatMessage); // 全員にブロードキャスト
                break;

            // 将来的にユーザー名変更などの他のメッセージタイプを追加可能
            // case 'change_username':
            //     // ... ユーザー名変更ロジック ...
            //     break;

            default:
                console.warn(`Received unknown message type from ${ws.username}: ${parsedMessage.type}`);
                sendToClient(ws, {
                    type: 'error',
                    payload: { code: 400, message: `Unknown message type: ${parsedMessage.type}` }
                });
                // プロトコルエラーとして接続を切断することも検討
                // ws.close(1002, 'Protocol Error');
        }

    } catch (error) {
        console.error(`Failed to parse message from ${ws.username}:`, error);
         sendToClient(ws, {
             type: 'error',
             payload: { code: 400, message: 'Failed to parse message (Invalid JSON).' }
         });
        // JSONパースエラーの場合、接続を切断することも検討
        // ws.close(1007, 'Invalid frame payload data');
    }
});

ws.on('close', (code: number, reason: Buffer) => {
    console.log(`User disconnected: ${ws.username} (${ws.userId}) (Code: ${code}, Reason: ${reason.toString()})`);
    // ユーザーリストから削除
    delete users[ws.userId];
    // 他のクライアントに離脱を通知
    broadcast({
        type: 'user_leave',
        payload: { userId: ws.userId, username: ws.username }
    });
    // ユーザーリストを更新して全員にブロードキャスト
    updateUserList();
});

ws.on('error', (error: Error) => {
    console.error(`WebSocket error for user ${ws.username} (${ws.userId}):`, error);
    // エラー発生後、通常 close イベントが発生するため、ここではログ記録に留める
});

});

wss.on(‘error’, (error) => {
console.error(‘WebSocket server error:’, error);
});

console.log(‘WebSocket server started on port 8080’);
“`

2. クライアントサイド (public/index.ts)

HTMLのUI要素と連携し、メッセージの送受信、受信メッセージの表示、ユーザーリストの表示を行います。

“`typescript
// public/index.ts
// メッセージの型定義 (サーバーと共通のファイルを想定)
interface BaseMessage {
type: string;
}

interface ChatMessagePayload {
userId: string;
username: string; // ユーザー名を追加
text: string;
timestamp: number;
}

interface ChatMessage extends BaseMessage {
type: ‘chat’;
payload: ChatMessagePayload;
}

interface UserJoinMessagePayload {
userId: string;
username: string;
}

interface UserJoinMessage extends BaseMessage {
type: ‘user_join’;
payload: UserJoinMessagePayload;
}

interface UserLeaveMessagePayload {
userId: string;
username: string;
}

interface UserLeaveMessage extends BaseMessage {
type: ‘user_leave’;
payload: UserLeaveMessagePayload;
}

interface UserListMessagePayload {
users: { userId: string; username: string }[];
}

interface UserListMessage extends BaseMessage {
type: ‘user_list’;
payload: UserListMessagePayload;
}

interface ErrorMessagePayload {
code: number;
message: string;
}

interface ErrorMessage extends BaseMessage {
type: ‘error’;
payload: ErrorMessagePayload;
}

type AppMessage = ChatMessage | UserJoinMessage | UserLeaveMessage | UserListMessage | ErrorMessage;

const websocketUrl = ws://localhost:8080; // サーバーのアドレスに合わせる

let websocket: WebSocket | null = null;
let reconnectAttempts = 0;
const maxReconnectAttempts = 10;
const initialReconnectDelay = 1000;

// UI要素を取得
const messagesDiv = document.getElementById(‘messages’);
const messageInput = document.getElementById(‘messageInput’) as HTMLInputElement;
const sendButton = document.getElementById(‘sendButton’);
const userListDiv = document.getElementById(‘userList’); // HTMLにユーザーリスト表示用の要素を追加する必要がある

// 受信メッセージを表示する関数
function displayMessage(message: string, sender: string, type: ‘chat’ | ‘system’ | ‘error’): void {
if (messagesDiv) {
const messageElement = document.createElement(‘p’);
let classNames = [‘message’, type];
if (sender) classNames.push(sender === ‘System’ ? ‘system-sender’ : ‘chat-sender’); // 簡易的なクラス付与

    messageElement.classList.add(...classNames);
    // 時刻表示を追加 (任意)
    const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
    messageElement.innerHTML = `[${time}] <strong>${sender}:</strong> ${message}`;

    messagesDiv.appendChild(messageElement);
    // 最新メッセージが見えるようにスクロール
    messagesDiv.scrollTop = messagesDiv.scrollHeight;
}

}

// ユーザーリストを表示/更新する関数
function updateUserListDisplay(users: { userId: string; username: string }[]): void {
if (userListDiv) {
userListDiv.innerHTML = ‘Online Users:

    ‘ +
    users.map(user => <li>${user.username}</li>).join(”) +

‘;
}
}

// WebSocket接続を確立する関数
function connectWebSocket(): void {
if (websocket && websocket.readyState !== WebSocket.CLOSED) {
console.log(‘Closing existing connection before attempting reconnect.’);
websocket.close();
}

if (reconnectAttempts >= maxReconnectAttempts) {
    console.error('Max reconnect attempts reached. Giving up.');
    displayMessage('Connection failed after multiple attempts.', 'System', 'system');
    return;
}

const delay = initialReconnectDelay * Math.pow(2, reconnectAttempts);
console.log(`Attempting to connect to ${websocketUrl} (Attempt ${reconnectAttempts + 1}/${maxReconnectAttempts}, delay ${delay}ms)`);
displayMessage(`Attempting to connect... (Attempt ${reconnectAttempts + 1})`, 'System', 'system');


setTimeout(() => {
    websocket = new WebSocket(websocketUrl);

    websocket.onopen = (event: Event) => {
        console.log('WebSocket connection opened:', event);
        reconnectAttempts = 0; // 接続成功したらリトライ回数をリセット
        displayMessage('Connection established.', 'System', 'system');
        // UIの状態を更新 (入力フィールドを有効にするなど)
        if (messageInput) messageInput.disabled = false;
        if (sendButton) sendButton.disabled = false;
    };

    websocket.onmessage = (event: MessageEvent) => {
        if (typeof event.data !== 'string') {
            console.warn('Received non-string message, ignoring.');
            return;
        }

        try {
            const message: AppMessage = JSON.parse(event.data); // ここで型アサーション (zodなどで検証するとより安全)

            switch (message.type) {
                case 'chat':
                    displayMessage(message.payload.text, message.payload.username, 'chat');
                    break;
                case 'user_join':
                    displayMessage(`${message.payload.username} joined the chat.`, 'System', 'system');
                    // ユーザーリストは 'user_list' メッセージで更新される想定
                    break;
                 case 'user_leave':
                    displayMessage(`${message.payload.username} left the chat.`, 'System', 'system');
                     // ユーザーリストは 'user_list' メッセージで更新される想定
                    break;
                 case 'user_list':
                    updateUserListDisplay(message.payload.users);
                    break;
                case 'error':
                    displayMessage(`Error (${message.payload.code}): ${message.payload.message}`, 'System', 'error');
                    break;
                default:
                    console.warn(`Received unknown message type: ${message.type}`, message);
                     displayMessage(`Received unknown message type: ${message.type}`, 'System', 'system');
            }

        } catch (error) {
            console.error('Failed to parse incoming message:', error);
            displayMessage('Received invalid message format from server.', 'System', 'error');
        }
    };

    websocket.onerror = (event: Event) => {
        console.error('WebSocket error observed:', event);
        displayMessage('Connection Error.', 'System', 'error');
        // エラー発生後、通常 close イベントも発生する
    };

    websocket.onclose = (event: CloseEvent) => {
        console.log('WebSocket connection closed:', event.code, event.reason);
        websocket = null; // インスタンスをクリア
         // UIの状態を更新 (入力フィールドを無効にするなど)
        if (messageInput) messageInput.disabled = true;
        if (sendButton) sendButton.disabled = true;


        if (event.code === 1000) {
            console.log('Connection closed normally.');
             displayMessage('Connection closed.', 'System', 'system');
        } else {
            console.log('Connection closed with error or abnormal closure. Retrying...');
            reconnectAttempts++;
            connectWebSocket(); // 再接続を試みる
        }
    };
}, delay);

}

// メッセージを送信する関数
function sendChatMessage(text: string): void {
if (websocket && websocket.readyState === WebSocket.OPEN) {
const message: ChatMessage = {
type: ‘chat’,
payload: {
// クライアント側では userId と username はサーバーが割り当てるため、ここでは含めない
// または、ここで一時的にクライアントIDなどを付与し、サーバーが上書きする
// 今回はサーバーが生成する前提
userId: ”, // サーバーが設定
username: ”, // サーバーが設定
text: text,
timestamp: Date.now() // クライアント側でタイムスタンプを付ける (サーバーが検証/上書きしても良い)
}
};
websocket.send(JSON.stringify(message));
console.log(‘Sent chat message:’, message);
// 送信したメッセージを即座に自分の画面に表示したい場合はここで呼び出し
// displayMessage(message.payload.text, ‘Me’, ‘chat’);
} else {
console.warn(‘WebSocket is not connected. Cannot send message.’);
displayMessage(‘Error: Not connected. Cannot send message.’, ‘System’, ‘error’);
}
}

// UIイベントリスナーの設定
if (sendButton && messageInput) {
sendButton.addEventListener(‘click’, () => {
const message = messageInput.value.trim();
if (message) {
sendChatMessage(message);
messageInput.value = ”; // 入力フィールドをクリア
}
});

messageInput.addEventListener('keypress', (event) => {
    if (event.key === 'Enter') {
        event.preventDefault();
        sendButton.click();
    }
});

// 初期状態では入力フィールドとボタンを無効にしておく
messageInput.disabled = true;
sendButton.disabled = true;

}

// ページロード時に接続を開始
window.addEventListener(‘load’, connectWebSocket);
“`

3. HTMLファイル (public/index.html)

ユーザーリスト表示用の要素と、簡単なスタイルを追加します。

“`html







TypeScript WebSocket Chat


TypeScript WebSocket Chat





“`

実行方法:

  1. サーバープロジェクト (my-websocket-server) に移動し、依存関係をインストールします (npm install ws typescript @types/ws ts-node uuid @types/uuid).
  2. クライアントプロジェクト (public) に移動し、バンドラをインストールします (npm install --save-dev parcel).
  3. それぞれの package.json に適切なスクリプトを設定します ("dev": "ts-node src/server.ts" for server, "start-client": "parcel public/index.html" for client)。
  4. ターミナルを2つ開き、それぞれでサーバー (npm run dev) とクライアント (npm run start-client) を起動します。
  5. ブラウザでクライアントが表示されたURL(通常 http://localhost:1234 など Parcel が表示するアドレス)にアクセスします。
  6. 複数のタブやブラウザでアクセスすると、簡易的なチャット機能を確認できます。

この例はあくまで基本的な実装であり、プロダクションレベルのアプリケーションには、認証、永続化、より高度なエラー処理、セキュリティ対策、スケーラビリティ対応など、多くの機能が必要になります。しかし、WebSocketとTypeScriptを使ってリアルタイムアプリケーションを構築するための良い出発点となるでしょう。

まとめ

本記事では、TypeScriptを用いてWebSocket通信のサーバーおよびクライアントを構築する方法を詳細に解説しました。

WebSocketは、従来のHTTP通信にはない全二重通信、低オーバーヘッド、低レイテンシといった利点を提供し、リアルタイム性の高いウェブアプリケーション開発に不可欠な技術です。

サーバーサイドでは、Node.jsと軽量な ws ライブラリを使い、接続管理、イベントハンドリング、メッセージ送受信の基本を学びました。クライアントサイドでは、ブラウザ標準の WebSocket APIを利用し、同様に接続、イベント処理、メッセージ交換の方法を確認しました。

TypeScriptを活用することで、メッセージフォーマットや接続状態を型安全に扱い、開発効率とコードの保守性を向上させることができました。特に、受信メッセージのパースと検証において型の恩恵を大きく受けられます。

また、実践的なアプリケーションを構築する上で重要な、メッセージプロトコルの設計、エラー処理、セキュリティ対策(WSS、オリジンチェック、認証・認可、入力検証)、そしてスケーラビリティ(Pub/Subパターン)についても触れました。

紹介したチャットアプリケーションの例は出発点に過ぎませんが、これらの基礎知識とベストプラクティスを応用することで、より複雑で要求の厳しいリアルタイムアプリケーションも構築できるようになるでしょう。

WebSocketの世界は奥深く、さらにパフォーマンス最適化や代替ライブラリ(Socket.IOなど)、将来的な技術(WebTransportなど)についても学ぶ価値があります。しかし、この記事で習得した内容は、WebSocketを使い始めるための強固な基盤となるはずです。

ぜひ、実際にコードを書いて動かし、WebSocket通信の可能性を体験してみてください。

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール