Hono × Cloudflare WorkersでSSEを実装——streamSSEからEventSource受信まで

Hono × Cloudflare WorkersでSSEを実装——streamSSEからEventSource受信まで

※本記事にはプロモーション(広告・アフィリエイトリンク)を含みます。

HonoのAPIルートにSSEエンドポイントを追加したい、fetchでポーリングするのはサーバー負荷が気になる——そういった場面でServer-Sent Events(SSE)は有効な選択肢です。この記事ではHono + Cloudflare Workersの構成でSSEエンドポイントを実装し、ブラウザのEventSourceで受信するまでの完全な手順を示します。コピーして動かせる最小実装と、詰まりやすいポイントをセットで説明します。

SSEとWebSocketの使い分け

SSE(Server-Sent Events)はHTTP/1.1の長時間接続を使い、サーバーからクライアントへ一方向にメッセージを送り続けるプロトコルです。WebSocketとの違いは次のとおりです。

項目 SSE WebSocket
通信方向 サーバー→クライアントのみ 双方向
プロトコル HTTP WS(アップグレード)
自動再接続 ブラウザが組み込みで行う 自前実装が必要
プロキシ通過 比較的容易 環境依存
Cloudflare Workers対応 ◎(fetch APIベース) ○(Durable Objectsが必要な場面も)

ジョブ進捗の通知・ログ配信・AIテキストのストリーミングなど「クライアントが送るのはリクエスト1回、受け取るのは複数回」というユースケースには、SSEが素直な選択です。

環境の準備

執筆時点のバージョンを前提に進めます。バージョンは将来変わりうるため、実際に作業する際は各パッケージの最新のリリースノートを確認してください。

npm create cloudflare@latest my-sse-worker -- --template hono
cd my-sse-worker
npm install

wrangler.tomlにcompatibility_dateが入っていることを確認します。

name = "my-sse-worker"
main = "src/index.ts"
compatibility_date = "2024-11-01"

サーバー側:HonoでSSEエンドポイントを実装する

HonoにはSSE専用のヘルパーstreamSSEがhono/streamingから提供されています。レスポンスヘッダー(Content-Type: text/event-streamなど)はヘルパーが自動で設定するため、手動で書く必要はありません。

// src/index.ts
import { Hono } from 'hono'
import { streamSSE } from 'hono/streaming'
import { cors } from 'hono/cors'

const app = new Hono()

// クロスオリジンで呼ぶ場合はCORSミドルウェアを追加
app.use('/sse/*', cors({
  origin: 'https://your-frontend.example.com',
  allowHeaders: ['Cache-Control'],
}))

app.get('/sse/events', (c) => {
  const signal = c.req.raw.signal

  return streamSSE(c, async (stream) => {
    let id = 0

    while (true) {
      // クライアントが切断したらループを抜ける
      if (signal.aborted) break

      await stream.writeSSE({
        id: String(id),
        event: 'ping',
        data: JSON.stringify({ timestamp: Date.now(), count: id }),
      })

      id++
      await stream.sleep(2000) // 2秒ごとに送信
    }
  })
})

export default app

writeSSEの引数

writeSSEに渡すSSEMessageオブジェクトには4つのフィールドがあります。

フィールド 型 役割
data string(必須) ペイロード本体。改行を含む場合は自動的に複数行のdata:に分割される
event string イベント種別。クライアントはaddEventListenerでフィルタできる
id string イベントID。切断時の再接続でLast-Event-IDヘッダーとして送られる
retry number クライアントの再接続間隔(ms)。省略時はブラウザのデフォルト値

切断検知について

c.req.raw.signalはリクエストに紐づいたAbortSignalです。クライアントがタブを閉じたりevtSource.close()を呼んだりすると、signal.abortedがtrueになります。ループの先頭でチェックすることで、切れたコネクションへ送り続けるのを防げます。

Cloudflare Workersの無料プランにはリクエストあたりのCPU時間制限があります。stream.sleep()による待機中はCPU時間が消費されないため問題になりにくいですが、ループ内に重い処理を書くとCPU時間を消費します。詳しくはCloudflare Workers 無料プランのCPU時間超過エラー回避方法を参照してください。

クライアント側:EventSourceで受信する

ブラウザ標準のEventSource APIはSSE専用に設計されています。fetchは不要で、追加ライブラリも必要ありません。

// ブラウザ側のコード(TypeScript)
const evtSource = new EventSource(
  'https://my-sse-worker.example.workers.dev/sse/events'
)

// event: 'ping' を受信するハンドラ
evtSource.addEventListener('ping', (e: MessageEvent) => {
  const payload = JSON.parse(e.data) as { timestamp: number; count: number }
  console.log(`count: ${payload.count}, at: ${new Date(payload.timestamp).toISOString()}`)
})

// エラーハンドリング
evtSource.onerror = () => {
  // ブラウザはデフォルトで自動再接続を試みる
  // 完全に終了させたい場合のみ close() を呼ぶ
  if (evtSource.readyState === EventSource.CLOSED) {
    console.log('接続がクローズされました')
  }
}

// コンポーネントのアンマウント時などに接続を閉じる場合
// evtSource.close()

サーバー側でeventフィールドを省略して送ると、クライアントはevtSource.onmessageで受け取ります。eventを指定した場合は必ずaddEventListenerでハンドラを登録してください。onmessageはeventなし(またはevent: message)にのみマッチします。

認証が必要な場合

EventSourceはカスタムリクエストヘッダーを付けられません。認証が必要なエンドポイントでは次のいずれかを選びます。

  1. クエリパラメータにトークンを渡す — URLに残るためスコープを絞って使う
    new EventSource('/sse/events?token=xxx')
  2. セッションCookieで認証する — EventSourceはCookieを自動送信するため相性がよい
  3. fetch + ReadableStreamで自前実装する — ヘッダーを自由に付けられる。サーバー側はHonoのPOSTルートにしてstreamSSEをそのまま使える

ローカル確認とデプロイ

# ローカル開発サーバーを起動
npx wrangler dev

# 別ターミナルでcurlにより動作確認
curl -N http://localhost:8787/sse/events

-N(--no-buffer)オプションでcurlのバッファリングを無効にするのがポイントです。これを付けないと受信データがまとまって表示されます。

期待される出力:

id: 0
event: ping
data: {"timestamp":1700000000000,"count":0}

id: 1
event: ping
data: {"timestamp":1700000002000,"count":1}

動作確認後、デプロイします。

npx wrangler deploy

つまずきやすいポイント

nginxリバースプロキシ経由でバッファリングが起きる

Workers自体はバッファリングしませんが、Cloudflare WorkersをnginxのリバースプロキシやLB経由で公開している場合、nginxがレスポンスをバッファリングしてまとめて送ることがあります。対策として次のヘッダーを追加します。

app.get('/sse/events', (c) => {
  c.header('X-Accel-Buffering', 'no') // nginxリバースプロキシ対策

  return streamSSE(c, async (stream) => {
    // ...
  })
})

Cloudflare Workers → インターネット → ブラウザという直接の構成であれば、この問題は発生しません(環境によって異なります)。

EventSourceは常にGETリクエスト

仕様上EventSourceはGETしか送れません。POSTでSSEを受けたい場合はfetch APIとReadableStreamを組み合わせた実装になります。HonoのStreamingAPIはPOSTルートでも動作するため、サーバー側の変更は最小限です。

同一オリジン以外からの接続

EventSourceはCORSに従います。Workerとフロントエンドのオリジンが異なる場合は、冒頭のコード例のようにHonoのcorsミドルウェアを忘れずに設定してください。AllowHeadersにCache-Controlを含めないとプリフライトが通らない場合があります。

まとめ

HonoのSSE実装はstreamSSEヘルパーを使うことで、ヘッダー設定やストリームのフラッシュを意識せずに書けます。クライアントはブラウザ標準のEventSourceをそのまま使えるため、追加ライブラリは不要です。要点を整理します。

  • サーバー: streamSSE + writeSSEでイベントを送る。c.req.raw.signal.abortedで切断を検知してループを終了する
  • クライアント: new EventSource(url) + addEventListenerで特定イベントを受信。自動再接続はブラウザが行う
  • 確認: curl -Nでバッファリングなしに動作確認できる
  • 注意: 認証・CORS・nginxのバッファリングは環境に応じて対処する

まずは本記事の最小実装をコピーしてnpx wrangler devで起動し、curl -Nでイベントが届くことを確認してみてください。動いたらwriteSSEのペイロードをD1やKVから読んだ実データに差し替えるのが自然な次のステップです。

よくある質問

EventSourceでPOSTリクエストやカスタムヘッダーを送れますか?

EventSourceはGETのみで、カスタムヘッダーも付けられません。認証ヘッダーが必要な場合はfetch APIとReadableStreamを組み合わせてクライアントを自前実装します。サーバー側はHonoのPOSTルートにstreamSSEをそのまま使えます。

SSEの接続が切れたとき自動で再接続されますか?

ブラウザのEventSourceは仕様上、接続が切れると自動的に再接続を試みます。再接続間隔はブラウザ実装依存のデフォルト値が使われ、サーバー側からretryフィールドで上書き指定も可能です。完全に接続を終了したい場合はclose()を呼びます。

Cloudflare Workers無料プランでSSEの長時間接続は動きますか?

動作します。無料プランはリクエストあたり10msのCPU時間制限がありますが、stream.sleep()はI/O待機なのでCPUを消費しません。ただしループ内に重いデータ処理を書くとCPU時間を使うため、ループのロジックはできるだけ薄くする必要があります。

HonoのstreamSSEはlast-event-idによる再接続に対応していますか?

writeSSEのidフィールドにイベントIDを渡すことでSSE仕様のid:行が出力されます。クライアントが再接続する際にLast-Event-IDヘッダーを送るかはブラウザの実装依存ですが、サーバー側ではc.req.header('Last-Event-ID')で読み取って続きから配信する実装が可能です。