WebSocket / Server-Sent Events
サーバから能動的にデータを送る2つの仕組み。双方向なら WebSocket、一方向(サーバ→クライアント)で十分なら SSE。 LLM ストリーミングの普及で SSE が再評価されている。
選び方
| WebSocket | SSE | Long Polling | |
|---|---|---|---|
| 方向 | 双方向 | サーバ→クライアント | クライアント主導 |
| プロトコル | WS(HTTP Upgrade) | HTTP/1.1+ | HTTP |
| 再接続 | 自前 | 自動 | 自動(ループ) |
| HTTP/2 多重化 | ×(独自) | ○ | ○ |
| バイナリ | ○ | × | × |
| 用途 | チャット・ゲーム | 通知・LLM ストリーミング | レガシー対応 |
| ファイアウォール | たまに blocked | HTTP なので OK | HTTP なので OK |
WebSocket
クライアント
const ws = new WebSocket("wss://example.com/ws")
ws.addEventListener("open", () => {
console.log("connected")
ws.send(JSON.stringify({ type: "hello" }))
})
ws.addEventListener("message", (e) => {
const data = JSON.parse(e.data)
console.log(data)
})
ws.addEventListener("close", (e) => {
console.log("closed", e.code, e.reason)
})
ws.addEventListener("error", (e) => {
console.error(e)
})
// 送信
ws.send("text")
ws.send(JSON.stringify({ a: 1 }))
ws.send(buffer) // Blob / ArrayBuffer 可
// 切断
ws.close(1000, "normal")
readyState
0CONNECTING1OPEN2CLOSING3CLOSED
サーバ(Node.js + ws)
npm install ws
import { WebSocketServer } from "ws"
const wss = new WebSocketServer({ port: 8080 })
wss.on("connection", (ws, req) => {
console.log("client:", req.socket.remoteAddress)
ws.on("message", (data) => {
const msg = JSON.parse(data.toString())
ws.send(JSON.stringify({ echo: msg }))
})
ws.on("close", () => console.log("disconnected"))
})
サーバ(Cloudflare Workers)
// Workers + Durable Objects が定石
export class ChatRoom {
state: DurableObjectState
sessions = new Set<WebSocket>()
async fetch(req: Request) {
const pair = new WebSocketPair()
const [client, server] = [pair[0], pair[1]]
server.accept()
this.sessions.add(server)
server.addEventListener("message", (e) => {
for (const ws of this.sessions) ws.send(e.data)
})
server.addEventListener("close", () => {
this.sessions.delete(server)
})
return new Response(null, { status: 101, webSocket: client })
}
}
再接続パターン
function connect(url, onMessage) {
let ws
let retries = 0
const open = () => {
ws = new WebSocket(url)
ws.onopen = () => { retries = 0 }
ws.onmessage = onMessage
ws.onclose = () => {
const wait = Math.min(1000 * 2 ** retries, 30000)
retries++
setTimeout(open, wait)
}
}
open()
return () => ws?.close()
}
ハートビート
ファイアウォールが切らないように定期的に pingを送る。
setInterval(() => {
if (ws.readyState === 1) ws.send(JSON.stringify({ type: "ping" }))
}, 30_000)
ライブラリ
- Socket.IO — フォールバック・room・自動再接続。古典的
- SocketCluster — クラスタリング
- Phoenix Channels — Elixir 製
- PartyKit — Cloudflare DO ベースの新興
- Liveblocks / Yjs — リアルタイムコラボ
Server-Sent Events (SSE)
サーバが長時間 HTTP レスポンスを開いたまま、テキストイベントを流し続ける。
Content-Type: text/event-stream。
クライアント
const es = new EventSource("/api/stream")
es.addEventListener("message", (e) => {
console.log(e.data)
})
es.addEventListener("update", (e) => { // カスタムイベント名
console.log("update:", e.data)
})
es.addEventListener("error", (e) => {
console.error(e)
})
es.close()
サーバ送信フォーマット
data: hello
event: update
id: 42
data: {"x":1}
retry: 5000
data: reconnect after 5s
- 2 つの改行でメッセージ区切り
data:必須。複数行にできる(連続した data 行は改行で連結)event:イベント名(既定message)id:最後の ID。再接続時にLast-Event-IDヘッダーで送られるretry:ms 単位の再接続待ち時間
サーバ実装(Hono)
import { streamSSE } from "hono/streaming"
app.get("/api/stream", (c) => {
return streamSSE(c, async (stream) => {
let id = 0
while (true) {
await stream.writeSSE({
id: String(id++),
event: "tick",
data: JSON.stringify({ ts: Date.now() }),
})
await stream.sleep(1000)
}
})
})
素の Node.js
app.get("/stream", (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", // Nginx バッファ無効
})
const id = setInterval(() => {
res.write(`data: ${JSON.stringify({ ts: Date.now() })}\n\n`)
}, 1000)
req.on("close", () => clearInterval(id))
})
SSE の自動再接続
ブラウザが自動で再接続する。Last-Event-ID ヘッダーで前回の ID が送られるので、
サーバ側で続きから送る実装が可能。
SSE の典型用途
- LLM のストリーミング応答(ChatGPT 風)
- 進捗通知(バックエンドのジョブ進行)
- 株価・センサーデータ
- 通知のリアルタイム配信
LLM ストリーミングの例
const es = new EventSource("/api/chat?prompt=...")
let text = ""
es.addEventListener("delta", (e) => {
text += JSON.parse(e.data).text
output.textContent = text
})
es.addEventListener("done", () => es.close())
SSE の制約
- テキストのみ(バイナリは Base64 等で)
- サーバ→クライアントの一方向
- HTTP/1.1 では1ドメイン6接続制限。HTTP/2 なら問題なし
- ブラウザは原則 EventSource API を使うが、
fetch+ReadableStreamでもパースできる
fetch ベースの SSE 風処理
EventSource はカスタムヘッダー(Authorization)を送れない。fetch + ReadableStream で代替:
async function streamLLM(prompt) {
const res = await fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ prompt }),
})
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const events = buffer.split("\n\n")
buffer = events.pop()
for (const e of events) {
const data = e.match(/^data: (.+)$/m)?.[1]
if (data) yield JSON.parse(data)
}
}
}
Long Polling(参考)
旧来の手法。クライアントがfetch を投げて、サーバが何かあるまで待つ。 受け取ったら即再 fetch。WebSocket / SSE が使えない環境でのフォールバック。
WebTransport(次世代)
HTTP/3(QUIC)ベースの新しいプロトコル。双方向 + 多重ストリーム + データグラム。 ゲームや低遅延ストリーミング向け。Chrome / Edge で対応進行中。Safari / Firefox は未実装。
const transport = new WebTransport("https://example.com/wt")
await transport.ready
const writer = transport.datagrams.writable.getWriter()
writer.write(new Uint8Array([1, 2, 3]))
- チャット / マルチプレイヤゲーム: WebSocket
- LLM 応答ストリーミング / 通知: SSE
- 低遅延ゲーム: WebTransport(実験)/ WebSocket
- フォールバック必須: Socket.IO(自動でロングポーリングへ)