WebSocket / Server-Sent Events

サーバから能動的にデータを送る2つの仕組み。双方向なら WebSocket、一方向(サーバ→クライアント)で十分なら SSE。 LLM ストリーミングの普及で SSE が再評価されている。

選び方

WebSocketSSELong Polling
方向双方向サーバ→クライアントクライアント主導
プロトコルWS(HTTP Upgrade)HTTP/1.1+HTTP
再接続自前自動自動(ループ)
HTTP/2 多重化×(独自)
バイナリ××
用途チャット・ゲーム通知・LLM ストリーミングレガシー対応
ファイアウォールたまに blockedHTTP なので OKHTTP なので 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

サーバ(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)

ライブラリ

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

サーバ実装(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 ストリーミングの例

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 の制約

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(自動でロングポーリングへ)