NestJS PDF非同期生成とトークンダウンロード
BullMQでPDF生成をキューイングし、Playwrightで HTML→PDF変換、使い捨てトークンで安全にダウンロードさせるパターンを実装する
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をダウンロードできる
前提
- Node.js 20+、NestJS 10+
- NestJS BullMQでジョブキューを実装する の知識(BullMQ・Redisの基本)
- Docker(Redisをローカルで動かす場合)
全体の処理フロー
この記事で実装するのは次のフローである。
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点。
waitUntilFinished()でProducerがConsumerの完了を待つため、POST /pdf/generateの1レスポンスでトークンまで返せる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 chromium2. 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:devPDFを生成してトークンを取得する。
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}`);関連
- NestJS BullMQでジョブキューを実装する
- NestJS Azure Blob Storageにファイルをアップロードする — 一時ファイルをローカルではなくAzure Blobに保存したい場合
- Playwright page.pdf() — 公式ドキュメント