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はカスタムリクエストヘッダーを付けられません。認証が必要なエンドポイントでは次のいずれかを選びます。
- クエリパラメータにトークンを渡す — URLに残るためスコープを絞って使う
new EventSource('/sse/events?token=xxx') - セッションCookieで認証する —
EventSourceはCookieを自動送信するため相性がよい 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')で読み取って続きから配信する実装が可能です。