2026/06/04

PDF生成 リアルタイム進捗ストリーミング完全ガイド — SSEとX-Job-Idの使い方

sserealtimeapistreamingpdf

ポーリングとWebhookによるPDF生成ステータス追跡を紹介した記事では、非同期PDFジョブのステータスを確認する2つの代表的なアプローチを取り上げました。本記事ではその延長として、第3の選択肢 — Server-Sent Events (SSE) によるリアルタイム進捗ストリーミングを紹介します。

FUNBREW PDF APIは2026年5月31日のアップデートで、バッチジョブと単発生成ジョブの両方に対してSSEエンドポイントを追加しました。進捗バーやステータス表示を画面にリアルタイム反映したい場合に最も適したアーキテクチャです。

ポーリング・Webhook・SSEの3択比較

ポーリング Webhook SSE
リアルタイム性 インターバル遅延あり あり あり
受信サーバーが必要 不要 必要 不要
ブラウザから直接利用 可能 不可 可能
余分なAPIコール あり なし なし
中間進捗(%)を取得できる 不可 不可 可能
再接続時の取りこぼし防止 別途実装が必要 再配信で対応 Last-Event-ID で自動
向いているシーン CLIスクリプト・シンプルなサーバー処理 サーバー間の確実な通知 ブラウザUI・進捗表示

SSEの最大の利点は「公開HTTPエンドポイントが不要で、ブラウザのネイティブAPIだけで中間進捗を取得できる」点です。WebSocketのような双方向通信は不要で、ブラウザ標準の EventSource のみで接続できます。

SSEとは

Server-Sent Events (SSE) はHTTP上でサーバーからクライアントへのプッシュ配信を行う仕組みです。text/event-stream Content-Typeで接続を維持し、サーバーが随時データを送り込みます。

  • HTTP/1.1ベース — WebSocketと異なりプロキシ・Nginxとの相性が良い
  • ブラウザ標準EventSource APIが全モダンブラウザに内蔵
  • 自動再接続EventSource は切断時に自動で再接続を試みる
  • 取りこぼし防止Last-Event-ID ヘッダで続きから受信できる

FUNBREW PDF APIのSSEエンドポイント

エンドポイント仕様

GET /api/pdf/jobs/{jobId}/events
Accept: text/event-stream
X-API-Key: sk-xxxx
Last-Event-ID: <id>    # 任意。前回受信した最後のIDを指定すると続きから受信

jobId は2種類の方法で取得します。

ジョブの種類 jobIdの取得方法
バッチ生成 POST /api/pdf/batch レスポンスの batch_id
単発生成 (/generate-from-html 等) レスポンスヘッダの X-Job-Id、またはリクエスト時に X-Job-Id ヘッダで事前指定

認証: 既存の PDF APIキー(generate / download スコープ)をそのまま使用します。Authorization: Bearer sk-xxxx 形式でも受け付けます。

X-Job-Idヘッダの役割

単発生成でSSEを使う場合、X-Job-Id が重要な役割を果たします。

  • 事前指定: UUID等を自分で生成し、X-Job-Id ヘッダを付けてジョブを投入すると、SSEを先に開いてから生成リクエストを送ることができる
  • 事後取得: X-Job-Id を省いてジョブを投入すると、レスポンスヘッダの X-Job-Id からIDを取得し、そのままSSEを購読できる

イベントの種類とペイロード

SSEストリームは progress イベントと retry ディレクティブを配信します。

retry: 3000

id: 1748649600123-1
event: progress
data: {"phase":"queued","progress":0.0,"message":"enqueued","data":{},"occurred_at":"2026-05-31T01:00:00+00:00"}

id: 1748649600456-2
event: progress
data: {"phase":"rendering","progress":0.5,"message":"rendering pdf","data":{},"occurred_at":"2026-05-31T01:00:01+00:00"}

id: 1748649600789-3
event: progress
data: {"phase":"done","progress":1.0,"message":"batch completed","data":{"batch_id":"...","total_items":3,"completed_items":3,"failed_items":0},"occurred_at":"2026-05-31T01:00:02+00:00"}

フェーズ一覧:

phase 意味
queued ジョブ受付完了
preprocessing HTML検証・フォント解決
rendering PDFレンダリング中
postprocess 透かし・ページ番号・メタデータ付与
uploading S3/Wasabiへのアップロード中
done 完了(終端 — サーバーがストリームを閉じる)
failed 失敗(終端 — サーバーがストリームを閉じる)

done または failed を受信するとサーバー側からストリームが閉じられます。クライアント側でも es.close() を呼ぶのが推奨です。

実装例

curlで試す(最も手軽)

# バッチジョブの場合
curl -N \
  -H "X-API-Key: $FUNBREW_PDF_API_KEY" \
  https://pdf.funbrew.cloud/api/pdf/jobs/<batch_uuid>/events

-N--no-buffer)は必須です。省くとバッファされてリアルタイムに見えません。

単発生成でSSEを先に開く場合:

# 1. jobIdを事前に生成
JOB_ID=$(uuidgen)

# 2. SSEを先に購読(バックグラウンドで待機)
curl -N -H "X-API-Key: $FUNBREW_PDF_API_KEY" \
  "https://pdf.funbrew.cloud/api/pdf/jobs/$JOB_ID/events" &

# 3. 同じjobIdで生成リクエストを投入
curl -X POST https://pdf.funbrew.cloud/api/pdf/generate-from-html \
  -H "X-API-Key: $FUNBREW_PDF_API_KEY" \
  -H "X-Job-Id: $JOB_ID" \
  -H "Content-Type: application/json" \
  -d '{"html":"<h1>Hello FUNBREW PDF</h1>"}'

または生成後にレスポンスヘッダからJobIdを取得する方法:

# ヘッダ付きでレスポンスを受け取る
RESP=$(curl -sD - -X POST https://pdf.funbrew.cloud/api/pdf/generate-from-html \
  -H "X-API-Key: $FUNBREW_PDF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"html":"<h1>Hello FUNBREW PDF</h1>"}')

JOB_ID=$(echo "$RESP" | grep -i 'X-Job-Id' | cut -d' ' -f2 | tr -d '\r')

# SSE購読
curl -N -H "X-API-Key: $FUNBREW_PDF_API_KEY" \
  "https://pdf.funbrew.cloud/api/pdf/jobs/$JOB_ID/events"

ブラウザ EventSource API

バックエンドAPIキーをブラウザに露出させない形で実装するには、自前のサーバーをプロキシとして経由させるか、APIキーをサーバーサイドで注入する構成をとってください。

const jobId = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'; // バッチIDまたはX-Job-Id

const es = new EventSource(`/api/pdf/jobs/${jobId}/events`, {
  withCredentials: false,
});

es.addEventListener('progress', (event) => {
  const payload = JSON.parse(event.data);

  // 進捗バーを更新
  updateProgressBar(payload.progress); // 0.0 〜 1.0

  // フェーズに応じてUIを更新
  showStatus(payload.phase, payload.message);

  // 終端フェーズで購読を終了
  if (payload.phase === 'done' || payload.phase === 'failed') {
    es.close();
    if (payload.phase === 'done') {
      console.log('完了:', payload.data);
    } else {
      console.error('失敗:', payload.message);
    }
  }
});

es.onerror = (err) => {
  // EventSourceは自動再接続する。Last-Event-IDも自動付与されるため、
  // 取りこぼしなく続きから受信できる。
  console.warn('SSE接続エラー(自動再接続中):', err);
};

Node.js(eventsourceパッケージ)

Node.jsでは EventSource がネイティブに存在しないため、eventsource パッケージを使います。Node.js 22以降はグローバルに EventSource が追加されています。

import EventSource from 'eventsource';

const baseUrl = 'https://pdf.funbrew.cloud';
const apiKey = process.env.FUNBREW_PDF_API_KEY;
const batchUuid = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';

const es = new EventSource(`${baseUrl}/api/pdf/jobs/${batchUuid}/events`, {
  headers: { 'X-API-Key': apiKey },
});

es.addEventListener('progress', (e) => {
  const payload = JSON.parse(e.data);
  console.log(`[${payload.phase}] ${Math.round(payload.progress * 100)}% — ${payload.message}`);

  if (payload.phase === 'done' || payload.phase === 'failed') {
    es.close();
    process.exit(payload.phase === 'done' ? 0 : 1);
  }
});

@funbrew/pdf-client SDK(推奨)

JS SDK (@funbrew/pdf-client) を使うと、EventSource の手動管理が不要になり、最もシンプルにSSE購読を実装できます。

import { PdfClient } from 'https://pdf.funbrew.cloud/sdk/pdf-client.esm.js';

const client = new PdfClient({
  baseUrl: 'https://pdf.funbrew.cloud',
  apiKey: 'sk-...', // APIキー
});

// 1. jobIdを先に決める
const jobId = crypto.randomUUID();

// 2. SSE購読を開始
const sub = client.subscribeToJob(jobId, {
  onProgress: (event) => {
    console.log(`[${event.phase}] ${Math.round(event.progress * 100)}%`);
    updateProgressBar(event.progress);
  },
  onDone: (event) => {
    console.log('PDF生成完了:', event.data?.download_url);
    sub.close();
  },
  onError: (err) => {
    console.error('SSEエラー:', err);
  },
});

// 3. 同じjobIdでジョブを投入(mergeやcompressでも同様)
await client.generateFromHtml(
  { html: '<h1>Hello FUNBREW PDF</h1>', options: { page_size: 'A4' } },
  { jobId }
);

subscribeToJob は内部で EventSource を管理し、Last-Event-ID の付与や接続エラー時の再接続も自動処理します。

フェーズ遷移: queued → preprocessing → rendering → postprocess → uploading → done(失敗時: failed

ポーリング・Webhook・SSEの使い分けマトリクス

シナリオ 推奨アプローチ
ブラウザに進捗バーを表示したい SSE
CLIスクリプト・cronジョブ ポーリング
サーバー間の確実な完了通知 Webhook
受信サーバーが持てない SSE またはポーリング
長時間バッチ(数分〜数十分) SSE + Webhook の併用が理想
短時間ジョブ(5秒以内) 同期API(ポーリング不要)
高並行(100件以上の同時ジョブ) Webhook(SSEは接続数制限に注意)

SSEとWebhookの併用が推奨: SSEは「クライアントが見ている間のUX向上」、Webhookは「最終結果の永続的な伝達」と役割が異なります。どちらか一方に頼るより、UI表示はSSE・バックエンド処理トリガーはWebhookと使い分けるのが本番設計のベストプラクティスです。

注意点と運用ヒント

PHP-FPMのワーカー占有

SSE接続中はPHP-FPMワーカーが1つ専有されます。大量の同時接続が見込まれる場合は次のいずれかを検討してください。

  • 専用FPMプールを分ける(SSE接続数分だけワーカーを増やす)
  • FrankenPHP / Octane / Swoole に切り替える(1プロセスで数千接続可能)

Nginxの設定

location /api/pdf/jobs/ {
    proxy_pass http://app;
    proxy_buffering off;          # 必須。バッファされるとリアルタイムにならない
    proxy_cache off;
    proxy_read_timeout 3600s;     # 長時間ジョブに対応
    proxy_http_version 1.1;
    proxy_set_header Connection '';
}

レスポンスヘッダに X-Accel-Buffering: no が付加されているためデフォルトでも動作しますが、proxy_buffering off を明示すると確実です。

タイムアウトと最大接続時間

PDF_PROGRESS_MAX_DURATION(デフォルト: 600秒)を超えると、サーバーがストリームを自動的に閉じます。EventSource の自動再接続と Last-Event-ID により、切断後も取りこぼしなく続きから受信できます。

環境変数 デフォルト 説明
PDF_PROGRESS_MAX_DURATION 600 1接続あたりの最大保持時間(秒)
PDF_PROGRESS_POLL_MS 500 サーバー側のポーリング間隔(ms)
PDF_PROGRESS_TTL 86400 RedisキーのTTL(秒)

再接続と取りこぼし防止

Last-Event-ID ヘッダを使うと、切断前に受信した最後のイベントIDからストリームの続きを取得できます。ブラウザの EventSource はこのヘッダを自動で付与します。

IDはRedis StreamのタイムスタンプベースID(<ms>-<seq> 形式)なので、長時間切断されていた場合でも正確に続きから受信できます。

古いIDへの対応

前回の接続から時間が経ちすぎて Last-Event-ID が参照できなくなった場合、サーバーは 410 Gone を返します。この場合は最新のジョブステータスをポーリングで確認するフォールバックを実装してください。

まとめ

FUNBREW PDF APIのSSEストリーミングは、ポーリングとWebhookに続く第3の選択肢として、特にブラウザUIへのリアルタイム進捗表示に適しています。

  • ブラウザ標準の EventSource のみで利用可能(WebSocket不要)
  • バッチ・単発ジョブ両方に対応。単発ジョブは X-Job-Id で事前・事後どちらでも購読可能
  • Last-Event-ID による取りこぼし防止 — 再接続時も続きから受信
  • SDK (subscribeToJob) で手間なく実装可能

長時間バッチジョブで進捗バーを表示しながら、完了後の処理はWebhookで行う「SSE + Webhookの二刀流」が本番環境での推奨構成です。

関連リンク

Powered by FUNBREW PDF