awesome-hacks
Docs

NestJS PDF非同期生成とトークンダウンロード

BullMQでPDF生成をキューイングし、Playwrightで HTML→PDF変換、使い捨てトークンで安全にダウンロードさせるパターンを実装する

最終更新:2026/06/16

NestJS BullMQでジョブキューを実装する まで終えている前提。
この記事では、PDF生成リクエストをBullMQのジョブキューに積み、ConsumerがPlaywrightでPDFを生成したあと使い捨てトークンを発行してクライアントにダウンロードさせるまでの一連のパターンを実装する。

TL;DR

  • PDF生成のような重い処理はBullMQのConsumerに委譲し、waitUntilFinished() でProducerが結果を受け取る
  • PlaywrightはHTMLをA4 PDFに変換できる。ブラウザレンダリング結果をそのままPDF化するため、CSSレイアウトがそのまま使える
  • ダウンロード用トークンに短いTTLを設ける「使い捨てURL」パターンでファイルの直接公開を避けられる

この記事でやること

  • BullMQのConsumerの中でPlaywrightを使いHTMLをPDFに変換する
  • 生成したPDFを一時ディレクトリに保存し、UUIDトークンをキーに紐付ける
  • トークンを受け取ったクライアントが GET /pdf/download でPDFをダウンロードできる

前提

全体の処理フロー

この記事で実装するのは次のフローである。

sequenceDiagram
    participant C as クライアント
    participant Ctrl as PdfController
    participant Svc as PdfService
    participant Q as BullMQ Queue
    participant Con as PdfConsumer
    participant PW as Playwright
    participant TS as TokenStore(インメモリ)

    C->>Ctrl: POST /pdf/generate
    Ctrl->>Svc: generate()
    Svc->>Q: queue.add('generate', { html })
    Note over Q,Con: 非同期でジョブ処理開始
    Q-->>Con: process(job)
    Con->>Con: HTML文字列を組み立て
    Con->>PW: page.setContent(html) → page.pdf()
    PW-->>Con: PDF Buffer
    Con->>Con: os.tmpdir() に一時ファイル保存
    Con->>TS: issue(filePath) → UUID発行・TTL設定
    Con-->>Q: return token(ジョブresultとして保存)
    Svc->>Svc: waitUntilFinished() でtokenを受け取り
    Svc-->>Ctrl: token
    Ctrl-->>C: 200 { token }

    C->>Ctrl: GET /pdf/download?token=xxx
    Ctrl->>TS: consume(token)
    TS-->>Ctrl: filePath(取得後に即削除)
    Ctrl->>Ctrl: ReadStream でPDFを送信
    Ctrl-->>C: application/pdf

ポイントは2点。

  1. waitUntilFinished() でProducerがConsumerの完了を待つため、POST /pdf/generate の1レスポンスでトークンまで返せる
  2. consume() はトークンを取得と同時に削除するため、同一トークンで2回ダウンロードできない

触るファイル一覧

api-sample/
└── src/
    ├── app.module.ts               # BullModule.forRoot追加、PdfModuleをimport
    └── pdf/
        ├── pdf.module.ts           # BullModule.registerQueue、providers登録
        ├── pdf.controller.ts       # POST /pdf/generate、GET /pdf/download
        ├── pdf.service.ts          # Producer: queue.add + waitUntilFinished
        ├── pdf.consumer.ts         # Consumer: Playwright → 一時ファイル保存 → トークン発行
        └── pdf-token.store.ts      # トークン → ファイルパスの対応を保持するインメモリストア

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

BullMQはnestjs-10で導入済みの想定。Playwrightを追加する。

pnpm add playwright
pnpm exec playwright install chromium

2. TokenStore — 使い捨てトークンの管理

トークンからファイルパスを引けるシンプルなインメモリストアを作る。

// src/pdf/pdf-token.store.ts
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';

interface TokenEntry {
  filePath: string;
  expiresAt: number;
}

const TTL_MS = 5 * 60 * 1000;

@Injectable()
export class PdfTokenStore {
  private readonly store = new Map<string, TokenEntry>();

  issue(filePath: string): string {
    const token = randomUUID();
    this.store.set(token, { filePath, expiresAt: Date.now() + TTL_MS });
    return token;
  }

  consume(token: string): string | null {
    const entry = this.store.get(token);
    if (!entry || Date.now() > entry.expiresAt) {
      this.store.delete(token);
      return null;
    }
    this.store.delete(token);
    return entry.filePath;
  }
}

consume は取得と同時に削除するため、同じトークンを2回使ってもファイルパスは返らない。

3. Consumer — HTMLをPDFに変換してトークンを発行する

// src/pdf/pdf.consumer.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { chromium } from 'playwright';
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { PdfTokenStore } from './pdf-token.store';

export const PDF_QUEUE = 'pdf';

@Processor(PDF_QUEUE)
export class PdfConsumer extends WorkerHost {
  constructor(private readonly tokenStore: PdfTokenStore) {
    super();
  }

  async process(job: Job<{ html: string }>): Promise<string> {
    const browser = await chromium.launch();
    try {
      const page = await browser.newPage();
      await page.setContent(job.data.html, { waitUntil: 'load' });

      const pdfBuffer = await page.pdf({ format: 'A4', printBackground: true });

      const tmpPath = path.join(os.tmpdir(), `report-${job.id}.pdf`);
      await fs.writeFile(tmpPath, pdfBuffer);

      return this.tokenStore.issue(tmpPath);
    } finally {
      await browser.close();
    }
  }
}

process() の戻り値はBullMQがジョブの「result」としてRedis上に保存する。Producerはこの値を waitUntilFinished() で受け取る。finally でブラウザを必ず閉じる点がポイント。閉じ忘れるとプロセスにChromiumが積み上がる。

4. Service — ジョブを投入して完了を待つ(Producer)

// src/pdf/pdf.service.ts
import { InjectQueue } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common';
import { Queue, QueueEvents } from 'bullmq';
import { PDF_QUEUE } from './pdf.consumer';

@Injectable()
export class PdfService {
  private readonly queueEvents: QueueEvents;

  constructor(@InjectQueue(PDF_QUEUE) private readonly queue: Queue) {
    this.queueEvents = new QueueEvents(PDF_QUEUE, {
      connection: { host: 'localhost', port: 6379 },
    });
  }

  async generate(html: string): Promise<string> {
    const job = await this.queue.add('generate', { html });
    const token = await job.waitUntilFinished(this.queueEvents);
    return token as string;
  }
}

QueueEvents はRedisのPub/SubでConsumerの完了通知を受け取るリスナー。接続情報は BullModule.forRoot の設定と合わせる必要がある。

5. Controller — エンドポイントを定義する

// src/pdf/pdf.controller.ts
import {
  BadRequestException,
  Controller,
  Get,
  NotFoundException,
  Post,
  Query,
  Res,
} from '@nestjs/common';
import { Response } from 'express';
import * as fs from 'fs';
import { PdfService } from './pdf.service';
import { PdfTokenStore } from './pdf-token.store';

@Controller('pdf')
export class PdfController {
  constructor(
    private readonly pdfService: PdfService,
    private readonly tokenStore: PdfTokenStore,
  ) {}

  @Post('generate')
  async generate(): Promise<{ token: string }> {
    const html = buildSampleHtml();
    const token = await this.pdfService.generate(html);
    return { token };
  }

  @Get('download')
  async download(@Query('token') token: string, @Res() res: Response) {
    if (!token) throw new BadRequestException('token is required');

    const filePath = this.tokenStore.consume(token);
    if (!filePath) throw new NotFoundException('token is invalid or expired');

    res.set({
      'Content-Type': 'application/pdf',
      'Content-Disposition': 'attachment; filename="report.pdf"',
    });
    fs.createReadStream(filePath).pipe(res);
  }
}

function buildSampleHtml(): string {
  return `<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <style>
      body { font-family: sans-serif; padding: 40px; }
      h1 { color: #333; }
      table { border-collapse: collapse; width: 100%; margin-top: 16px; }
      th, td { border: 1px solid #ccc; padding: 8px 12px; text-align: left; }
      th { background: #f5f5f5; }
    </style>
  </head>
  <body>
    <h1>サンプルレポート</h1>
    <p>生成日時: ${new Date().toLocaleString('ja-JP')}</p>
    <table>
      <tr><th>項目</th><th>値</th></tr>
      <tr><td>売上</td><td>1,234,567 円</td></tr>
      <tr><td>件数</td><td>42 件</td></tr>
    </table>
  </body>
</html>`;
}

buildSampleHtml は記事用の最小実装。実際のシステムではレポートデータをDBから取得し、テンプレートエンジンやReactで描画したHTMLを渡すことになる。

6. Module — 依存を組み立てる

// src/pdf/pdf.module.ts
import { BullModule } from '@nestjs/bullmq';
import { Module } from '@nestjs/common';
import { PDF_QUEUE, PdfConsumer } from './pdf.consumer';
import { PdfController } from './pdf.controller';
import { PdfService } from './pdf.service';
import { PdfTokenStore } from './pdf-token.store';

@Module({
  imports: [BullModule.registerQueue({ name: PDF_QUEUE })],
  controllers: [PdfController],
  providers: [PdfService, PdfConsumer, PdfTokenStore],
})
export class PdfModule {}
// src/app.module.ts(追記部分のみ)
import { BullModule } from '@nestjs/bullmq';
import { Module } from '@nestjs/common';
import { PdfModule } from './pdf/pdf.module';

@Module({
  imports: [
    BullModule.forRoot({ connection: { host: 'localhost', port: 6379 } }),
    PdfModule,
  ],
})
export class AppModule {}

7. 動作確認

Redisを起動してからAPIを動かす。

docker run -d --name redis-dev -p 6379:6379 redis:7-alpine
pnpm start:dev

PDFを生成してトークンを取得する。

curl -s -X POST http://localhost:3000/pdf/generate | jq .
# => { "token": "550e8400-e29b-41d4-a716-446655440000" }

トークンを使ってPDFをダウンロードする。

curl -o report.pdf \
  "http://localhost:3000/pdf/download?token=550e8400-e29b-41d4-a716-446655440000"

同じトークンで2回目を試すと404になる。

curl -v "http://localhost:3000/pdf/download?token=550e8400-e29b-41d4-a716-446655440000"
# => HTTP/1.1 404 Not Found

よくある落とし穴

Playwrightが「Browser not found」で起動しない

Consumer起動時に Error: browserType.launch: Executable doesn't exist at ... が出る場合、pnpm exec playwright install chromium が済んでいない。サーバー環境では playwright install --with-deps chromium で依存ライブラリも合わせてインストールする。

waitUntilFinished がConsumerのエラーを伝播する

Consumer内で例外が発生するとジョブはfailed状態になり、waitUntilFinished も同じエラーをスローする。PDF生成時のPlaywright例外(ページクラッシュなど)は最終的にHTTP 500としてクライアントに返る。Serviceで try-catch して適切なHTTPExceptionを投げるか、BullMQのリトライ設定(attempts)を検討する。

一時ファイルがディスクに残り続ける

現在の実装はダウンロード後もファイルをOSの一時ディレクトリに残す。次のようにストリームの終了イベントで削除することで後片付けできる。

import * as fsSync from 'fs';
import * as fsAsync from 'fs/promises';

fsSync.createReadStream(filePath)
  .pipe(res)
  .on('finish', () => fsAsync.unlink(filePath).catch(() => {}));

TTLが切れたトークンのファイルはこの方法では削除されないため、本番では定期クリーンアップのジョブも合わせて用意する。

Content-Disposition のファイル名が文字化けする

日本語ファイル名を使う場合は RFC 5987 エンコードが必要。

// 例: 「レポート.pdf」をファイル名にする
const encoded = encodeURIComponent('レポート.pdf');
res.set('Content-Disposition', `attachment; filename*=UTF-8''${encoded}`);

関連