2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Azure Functions + Hono で SSEによるストリーミングを実装する

Last updated at Posted at 2025-12-21

はじめに

サーバレス関数Azure Functions と 軽量Webフレームワーク Hono の構成でバックエンドAPIを構築しているシステムで、Server-Sent Events (SSE) によるストリーミング配信を実現したい……という事例があった。

SSEでの実装自体はそこまで難易度は高くなかったが、上記構成での実装方法に関する情報が非常に少なく、試実装中に少々難航した。
私の備忘録がてら、そして誰かの参考になることを祈り、今回採用した実装手順と、遭遇したハマりポイント/解決策を紹介する。

(そもそも Azure Functions + Hono の構成でシステムを作っている人がそんなにいない気がするが……)

この記事で実現すること

  • Azure Functions でHTTPストリーミングを有効化
  • Hono フレームワークを使ったSSEエンドポイントの実装
  • HTML+JSによるクライアント実装

サンプル実装

Server-Sent Events (SSE) とは

SSEは、サーバーからクライアントへ一方向にイベントをプッシュ配信するための標準仕様である。以下のような用途で活用されている:

  • リアルタイム通知
  • プログレス表示
  • ライブフィード
  • チャットアプリケーション(サーバー → クライアント方向)
  • AI ストリーミング応答(ChatGPT風)

WebSocketと比較して実装がシンプルで、HTTP/1.1上で動作するため、既存のインフラとの親和性も高いという特徴がある。

開発環境のセットアップ

環境

  • Node.js 20.x
  • Azure Functions Core Tools v4
  • Azure Static Web Apps CLI ※フロントからの接続確認を行いたい場合のみ

パッケージのインストール

{
  "dependencies": {
    "@azure/functions": "^4.5.0",
    "@hono/swagger-ui": "^0.5.2",
    "@hono/zod-openapi": "^1.1.5",
    "@marplex/hono-azurefunc-adapter": "^1.0.0",
    "hono": "^4.4.2",
    "zod": "^4.2.1"
  }
}

主要なパッケージの役割:

  • @azure/functions: Azure Functions v4 SDK
  • hono: 軽量Webフレームワーク
  • @marplex/hono-azurefunc-adapter: HonoアプリをAzure Functionsに統合するアダプター
  • @hono/zod-openapi: Honoのミドルウェア。OpenAPI仕様に基づいた型安全なAPI定義を実現する
  • @hono/swagger-ui: Honoのミドルウェア。構築したAPIの仕様を自動で Swagger UI として表示・操作可能にする。

Azure Functions での HTTPストリーミング有効化

Azure Functions v4 では、Node.js ランタイムでHTTPストリーミングがサポートされている。この機能を使うことで、レスポンスを一度にすべて返すのではなく、段階的にストリーミングできる。

設定方法

import { app } from "@azure/functions";

/**
 * Azure Functions アプリケーションのHTTPストリーミングを有効化
 */
app.setup({ enableHttpStream: true });

この1行を追加するだけで、ストリーミングが有効になる。

詳細は公式ドキュメントを参照すること:

Honoアプリケーションの実装

基本セットアップ

まず、OpenAPIHonoインスタンスを作成する。これにより、型安全なルート定義とOpenAPI仕様の自動生成が可能になる。

import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import { stream } from "hono/streaming";

const honoApp = new OpenAPIHono();

SSE エンドポイントの実装

基本的には、通常のGETエンドポイントと同じような書き方をすれば良いのだが、適切なレスポンス型とHTTPヘッダーを設定する必要がある。

const streamRoute = createRoute({
  method: "get",
  path: "/api/stream",
  summary: "SSEストリーム",
  description: "Server-Sent Eventsでリアルタイムにメッセージを配信",
  responses: {
    200: {
      description: "SSEストリーム開始",
      content: {
        "text/event-stream": { // @hono-zod-openapi の設定。MIME Typeを`text/event-stream`にする。
          schema: z.object({}),
        },
      },
    },
  },
});

honoApp.openapi(streamRoute, (c) => {
  // 重要: SSEに必要なヘッダーを設定
  c.header('Content-Type', 'text/event-stream');
  c.header('Cache-Control', 'no-cache');
  c.header('Connection', 'keep-alive');
  c.header('X-Accel-Buffering', 'no');
  
  return stream(c, async (stream) => {
    let id = 0;
    const startTime = Date.now();

    // 接続確立メッセージ
    await stream.writeln(`id: ${id}`);
    await stream.writeln("event: connect");
    await stream.writeln(
      `data: ${JSON.stringify({ type: "connected", message: "SSE stream started" })}`,
    );
    await stream.writeln(""); // 空行で区切り
    id++;

    // 定期的にデータを送信
    for (let i = 1; i <= 90; i++) {
      await stream.sleep(1000); // 1秒待機
      
      const elapsed = Math.floor((Date.now() - startTime) / 1000);
      const eventData = {
        type: "update",
        count: i,
        elapsed: `${elapsed}s`,
        timestamp: new Date().toISOString(),
        message: `Message ${i} - ${elapsed} seconds elapsed`,
      };

      await stream.writeln(`id: ${id}`);
      await stream.writeln("event: message");
      await stream.writeln(`data: ${JSON.stringify(eventData)}`);
      await stream.writeln("");
      id++;
    }

    // 終了メッセージ
    await stream.writeln(`id: ${id}`);
    await stream.writeln("event: complete");
    await stream.writeln(
      `data: ${JSON.stringify({ type: "complete", message: "Stream ended" })}`,
    );
    await stream.writeln("");
  });
});

重要なHTTPヘッダー

各ヘッダーの役割:

ヘッダー 役割
Content-Type: text/event-stream SSEの標準MIMEタイプ。これがないとブラウザがSSEとして認識しない
Cache-Control: no-cache レスポンスのキャッシュを無効化
Connection: keep-alive 接続を維持
X-Accel-Buffering: no nginxなどのリバースプロキシでのバッファリングを無効化

SSE メッセージフォーマット

SSEのメッセージは特定のフォーマットに従う必要がある:

id: 0
event: message
data: {"type":"update","message":"Hello"}

  • id: イベントID(再接続時に使用)
  • event: イベントタイプ(任意。デフォルトは"message")
  • data: ペイロード(JSONなど)
  • 空行: メッセージの区切り(必須)

Honoのstream.writeln()を使うと、各行に自動で改行が追加されるため、手動で\nを追加する必要はない。

実装時のハマりポイントと解決策

⚠️ 問題1: streamSSE() を使った際の chunked-encoding エラー

Hono では、SSE用の専用ヘルパーstreamSSE()も提供している。
最初はこれを使用したのだが、Azure Functions 環境で動かしてみると以下のエラーが発生した:

Error: The chunked upload is incomplete

@hono/node-serverを用い、通常のNodeサーバー上で起動した際は問題なく動作し、このエラーは発生しなかった。

原因:
Azure Functions の内部実装とstreamSSE()の実装方法に互換性の問題があったためと推測される。

解決策:
streamSSE()の代わりにstream()を使い、SSEフォーマットを手動で構築することで解決した。

// ❌ これだとエラー
import { streamSSE } from "hono/streaming";
return streamSSE(c, async (stream) => { ... });

// ✅ これで成功
import { stream } from "hono/streaming";
return stream(c, async (stream) => {
  c.header('Content-Type', 'text/event-stream');
  // SSEフォーマットを手動で構築
  await stream.writeln(`id: ${id}`);
  await stream.writeln("event: message");
  await stream.writeln(`data: ${JSON.stringify(data)}`);
  await stream.writeln("");
});

⚠️ 問題2: ストリームがバッファリングされる

データを送信しても、すべて完了するまでクライアントに届かない問題が発生した。

原因:
Azure Functions での HTTPストリーミング有効化 で解説した設定が漏れていた。(凡ミス……………)

解決策:
Azure Functions アプリケーションのHTTPストリーミングを有効にした。

api/src/functions/hono-sse.ts
import { app } from "@azure/functions";
/**
 * Azure Functions アプリケーションのHTTPストリーミングを有効化
 */
app.setup({ enableHttpStream: true });

⚠️ 問題3: MIME type エラー

ブラウザのコンソールに以下のエラーが表示された:

EventSource's response has a MIME type ("text/plain") that is not "text/event-stream". 
Aborting the connection.

原因:
Content-Type ヘッダーが正しく設定されていなかった。

解決策:
明示的に Content-Type: text/event-stream を設定した。

c.header('Content-Type', 'text/event-stream');

その他ポイント

Azure Functions への Hono 統合

HonoアプリをAzure Functionsで動かすために、@marplex/hono-azurefunc-adapterを使用する。

import {
  app,
  HttpRequest,
  HttpResponseInit,
  InvocationContext,
} from "@azure/functions";
import { azureHonoHandler } from "@marplex/hono-azurefunc-adapter";
import { honoApp } from "../apps";

// HTTPストリーミングを有効化
app.setup({ enableHttpStream: true });

// Honoハンドラー
export async function honoHandler(
  request: HttpRequest,
  context: InvocationContext,
): Promise<HttpResponseInit> {
  return azureHonoHandler(honoApp.fetch)(request, context);
}

// Azure Functions v4 プログラミングモデル
app.http("hono", {
  methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
  authLevel: "anonymous",
  handler: honoHandler,
  route: "{*path}", // ワイルドカードルーティング
});

Azure Function と Hono の統合については、下記の過去記事でも言及。

Azure Functions + Hono でバックエンドAPIを作成する #TypeScript - Qiita

Swagger UI によるAPIドキュメント化

@hono/zod-openapi@hono/swagger-uiを使うと、自動でAPIドキュメントが生成される。

import { swaggerUI } from "@hono/swagger-ui";

// OpenAPI仕様書のエンドポイント
honoApp.doc("/api/spec", {
  openapi: "3.0.0",
  info: {
    version: "1.0.0",
    title: "Azure Functions + Hono SSE API",
    description: "Honoを使用したServer-Sent Events (SSE) API",
  },
});

// Swagger UIエンドポイント
honoApp.get("/api/docs", swaggerUI({ url: "/api/spec" }));

これで /api/docs にアクセスすると、Swagger UIでAPIドキュメントを確認できる。

クライアント実装

ブラウザ側では、標準のEventSource APIを使用する。

<!DOCTYPE html>
<html>
<head>
  <title>SSE Test Client</title>
</head>
<body>
  <button id="connect">接続</button>
  <button id="disconnect">切断</button>
  <div id="messages"></div>

  <script>
    let eventSource = null;
    const messagesDiv = document.getElementById('messages');

    document.getElementById('connect').addEventListener('click', () => {
      // SSE接続を確立
      eventSource = new EventSource('/api/stream');

      // 接続イベント
      eventSource.addEventListener('connect', (e) => {
        const data = JSON.parse(e.data);
        console.log('Connected:', data);
      });

      // メッセージイベント
      eventSource.addEventListener('message', (e) => {
        const data = JSON.parse(e.data);
        messagesDiv.innerHTML += `<p>${data.message}</p>`;
      });

      // 完了イベント
      eventSource.addEventListener('complete', (e) => {
        const data = JSON.parse(e.data);
        console.log('Complete:', data);
        eventSource.close();
      });

      // エラーハンドリング
      eventSource.onerror = (error) => {
        console.error('SSE Error:', error);
        eventSource.close();
      };
    });

    document.getElementById('disconnect').addEventListener('click', () => {
      if (eventSource) {
        eventSource.close();
        console.log('Disconnected');
      }
    });
  </script>
</body>
</html>

EventSource の主なイベント

  • addEventListener(eventType, callback): カスタムイベントをリッスン
  • onmessage: デフォルトの"message"イベント
  • onerror: エラー発生時
  • close(): 接続を閉じる

パフォーマンス考慮事項

長時間接続の管理

Azure Functions には実行時間の制限がある:

  • Consumption プラン: 5分(デフォルト)~ 10分
  • Premium/App Service プラン: 最大30分(設定可能)
  • ※HTTP トリガー関数が要求に応答できる最大時間は 230 秒

長時間のSSE接続が必要な場合は、適切なプランを選択すること。

タイムアウト設定

host.jsonでタイムアウトを設定:

{
  "version": "2.0",
  "extensionBundle": {
    "id": "Microsoft.Azure.Functions.ExtensionBundle",
    "version": "[4.*, 5.0.0)"
  },
  "functionTimeout": "00:10:00"
}

まとめ

SSE実装時の注意点まとめ

  1. stream() を使用すべし: HonoのstreamSSE()ヘルパーは、Azure Functionsとの兼ね合いで問題が発生する可能性あり
  2. 必須ヘッダーを設定しよう: Content-Type, Cache-Control, Connection, X-Accel-Buffering
  3. 空行を忘れずに: SSEメッセージの区切りには空行が必要
  4. Azure Functions のHTTPストリーミング設定を確認: enableHttpStream: true設定が漏れるとストリームがバッファリングされてしまう
  5. タイムアウトに注意: Azure Functionsの実行時間制限を考慮しよう

参考資料

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?