はじめに
Node.jsでリアルタイム通信機能を作ろうとしたとき、真っ先に候補に挙がるのはSocket.ioかもしれません。自動再接続やroom機能、Long Pollingへのフォールバックまで揃っていて便利な反面、「WebSocketで双方向にメッセージをやり取りしたいだけなのに、機能が多すぎる」と感じたことはないでしょうか。
wsはその名のとおり、WebSocketプロトコルの実装だけに絞ったNode.js向けライブラリです。独自のメッセージフォーマットもroom機能も持たず、WebSocketとWebSocketServerという2つのクラスでプロトコルを愚直に実装しています。それでいてAutobahnという厳格なテストスイートに合格するほどの完成度を持ち、Socket.ioをはじめ多くのリアルタイム通信ライブラリが内部で利用してきた実績もあります。
wsとは
wsは、WebSocketのクライアントとサーバーの両方を実装したNode.js製ライブラリです。プロトコルの薄いラッパーに徹しているぶん動作が軽く、パフォーマンスと安定性を重視する場面で選ばれています。
なお、wsはブラウザでは動作しません。 READMEにも明記されているとおり、あくまでNode.js上でWebSocketの「サーバー」または「クライアント役のバックエンド」を実装するためのライブラリです。ブラウザ側では標準のWebSocketオブジェクトを使う必要があります。そのため本記事のサンプルはすべてNode.js環境での実行を前提にしています。
主な特徴
- プロトコル実装に特化 -
WebSocket(クライアント)とWebSocketServer(サーバー)という最小限のAPI構成で、独自の上位プロトコルを持ちません - 高速・軽量 - Autobahnテストスイートに完全準拠しつつ、バイナリアドオンの
bufferutilやutf-8-validateをオプトインで組み込むことでさらに高速化できます - 既存のHTTP/Sサーバーと統合可能 -
http.createServerやhttps.createServerで作ったサーバーにupgradeイベント経由で接続でき、パスごとに複数のWebSocketServerを振り分けられます - permessage-deflate圧縮に対応 - ペイロードの圧縮をネゴシエーションで有効化できます(デフォルトはクライアント側のみ有効)
- 切断検知の仕組みが用意されている -
ping/pongフレームを使って、応答のないコネクションを検出・切断する定石が確立されています - 多くのライブラリの土台になっている - Socket.ioのエンジン層など、上位のリアルタイム通信ライブラリから内部実装として利用されています
インストール
npmでプロジェクトに追加します。
npm install ws
Yarnやpnpmを使っている場合は次のとおりです。
yarn add ws
pnpm add ws
バイナリマスキング処理を高速化するbufferutilは、必要に応じてオプション依存として追加できます。
npm install --save-optional bufferutil
基本的な使い方
まずはサーバーとクライアントをそれぞれ最小構成で書いてみます。サーバー側はWebSocketServerをポート指定でインスタンス化するだけで、connectionイベントから各クライアントとのWebSocketインスタンスを受け取れます。
// server.js
import { WebSocketServer } from 'ws'
const wss = new WebSocketServer({ port: 8080 })
wss.on('connection', function connection(ws) {
ws.on('error', console.error)
ws.on('message', function message(data) {
console.log('received: %s', data)
})
ws.send('something')
})
クライアント側も同じwsパッケージのWebSocketクラスを使います。openイベントで接続完了を検知し、messageイベントで受信データを処理します。
// client.js
import WebSocket from 'ws'
const ws = new WebSocket('ws://localhost:8080')
ws.on('error', console.error)
ws.on('open', function open() {
ws.send('something')
})
ws.on('message', function message(data) {
console.log('received: %s', data)
})
node server.jsでサーバーを起動し、別ターミナルでnode client.jsを実行すると、sendで送ったメッセージが互いのmessageイベントに届くのが確認できます。
実践的なユースケース
サーバーブロードキャスト
チャットや通知配信のように、1人のクライアントからのメッセージを接続中の全員(または送信者以外の全員)に届けたい場面は多くあります。WebSocketServerインスタンスが持つclientsプロパティをforEachで回し、readyStateがWebSocket.OPENのものだけにsendすればブロードキャストが実現できます。
import WebSocket, { WebSocketServer } from 'ws'
const wss = new WebSocketServer({ port: 8080 })
wss.on('connection', function connection(ws) {
ws.on('error', console.error)
ws.on('message', function message(data, isBinary) {
wss.clients.forEach(function each(client) {
// 送信者以外にだけ配信する場合は client !== ws も条件に加える
if (client.readyState === WebSocket.OPEN) {
client.send(data, { binary: isBinary })
}
})
})
})
client !== wsの条件を外すと送信者自身にもメッセージが返るエコー配信になり、条件を加えると「自分以外の全員」への配信に切り替わります。バイナリデータを扱う場合はisBinaryフラグをsendの第2引数にそのまま渡すことで、テキスト/バイナリの種別を保ったまま中継できます。
切断検知とハートビート
WebSocketはTCP接続の上に成り立っているため、LANケーブルが抜けるようなケースでは、サーバーもクライアントも接続が切れたことに気づけません。この問題に対してwsは、ping/pongフレームによるハートビートという定石を提供しています。
import { WebSocketServer } from 'ws'
function heartbeat() {
this.isAlive = true
}
const wss = new WebSocketServer({ port: 8080 })
wss.on('connection', function connection(ws) {
ws.isAlive = true
ws.on('error', console.error)
ws.on('pong', heartbeat)
})
const interval = setInterval(function ping() {
wss.clients.forEach(function each(ws) {
if (ws.isAlive === false) return ws.terminate()
ws.isAlive = false
ws.ping()
})
}, 30000)
wss.on('close', function close() {
clearInterval(interval)
})
サーバーは30秒ごとに全クライアントへping()を送り、前回のpingからpongが返ってきていなければisAliveがfalseのままになるのでterminate()で強制切断します。pongはプロトコル仕様上クライアント側が自動応答するため、実装が必要なのはサーバー側のこのロジックだけです。間隔の30000(30秒)を短くするほど検知は早まりますが、その分ネットワーク負荷も増える点はトレードオフとして意識しておくとよいでしょう。
既存のHTTP/Sサーバーとの統合
APIサーバーと同じポートでWebSocketも待ち受けたい、あるいはパスごとに別々のWebSocketサーバーを振り分けたいというケースでは、http.createServerのupgradeイベントを使ってWebSocketServerを手動でアタッチします。noServer: trueにしておくと、WebSocketServer自身はポートを持たず、アップグレード処理だけを受け持つようになります。
import { createServer } from 'http'
import { WebSocketServer } from 'ws'
const server = createServer()
const wssChat = new WebSocketServer({ noServer: true })
const wssNotify = new WebSocketServer({ noServer: true })
wssChat.on('connection', (ws) => ws.on('error', console.error))
wssNotify.on('connection', (ws) => ws.on('error', console.error))
server.on('upgrade', function upgrade(request, socket, head) {
const { pathname } = new URL(request.url, 'http://base.url')
if (pathname === '/chat') {
wssChat.handleUpgrade(request, socket, head, (ws) => {
wssChat.emit('connection', ws, request)
})
} else if (pathname === '/notify') {
wssNotify.handleUpgrade(request, socket, head, (ws) => {
wssNotify.emit('connection', ws, request)
})
} else {
socket.destroy()
}
})
server.listen(8080)
pathnameごとにhandleUpgradeを呼び分けているのがポイントです。該当しないパスへのアップグレード要求はsocket.destroy()で拒否しています。このパターンを使えば、REST APIとWebSocketを同じHTTPサーバー・同じポートで共存させたり、認証チェックをupgradeイベント内に挟んでから接続を許可したりといった構成が組めます。
まとめ
wsは、WebSocketプロトコルの実装だけに徹することで、軽さと安定性を両立させてきたNode.js製ライブラリです。ブロードキャストもハートビートによる切断検知も、フレームワークが用意した機能に頼るのではなく、clientsプロパティやping/pongイベントといった薄いAPIを組み合わせて自分で書く形になります。その分コードは増えますが、何が起きているかが見える分、トラブルシューティングもしやすくなります。
Socket.ioのような高機能なライブラリが必要なほどではない、けれど標準のWebSocketだけではNode.js側の実装が心もとない——そんなときは、まずwsでサーバーとクライアントを1本ずつ書いてみることをおすすめします。
