PDFKit 标准字体未打入 Vercel serverless bundle 导致 500 错误,通过 serverExternalPackages 与 outputFileTracingIncludes 解决。
TL;DR: PDFKit 内置字体未被打包进 Vercel serverless 构建中,导致 /api/reports/tv-issues 接口返回 500 错误。通过将 pdfkit 添加到 serverExternalPackages,并扩展 outputFileTracingIncludes 以追踪 standard-fonts/*.cjs 文件,问题得以解决。
TV-issues 仪表板需要一个可导出的 PDF 报告(fuera de servicio、sin TV、sin MAC、sin Chromecast)。在本地环境下,/api/reports/tv-issues 路由运行正常,但在生产环境中返回 500 Internal Server Error。日志中只显示一条通用的"Error generating PDF report"错误信息,并未暴露根本原因——因为 PDFKit 在无法定位字体文件时静默失败了。
Vercel 日志中常见的错误:
Error generando reporte PDF de TV: Error: Cannot find module '/tmp/.../node_modules/pdfkit/js/data/Helvetica.afm'
PDFKit 从 node_modules/pdfkit/js/data/*.afm 和 node_modules/pdfkit/js/standard-fonts/*.cjs 加载标准字体。当 Next.js 构建 serverless bundle 时,只有明确被追踪的文件才会被复制到 lambda 中。默认情况下只追踪 JavaScript 文件;字体文件被遗漏了,导致 PDFKit 在运行时抛出错误。
我最初的反应是将其视为缺失的依赖:
npm install @types/pdfkit
我还围绕 PDF 生成逻辑添加了简单的 try/catch 以暴露错误:
export async function GET() {
try {
const report = await getTvIssuesReport();
// ... generate PDF
} catch (err) {
console.error("Error generando reporte PDF de TV:", err);
return new Response("PDF generation failed", { status: 500 });
}
}
这让我得到了真实的堆栈跟踪(Cannot find module …Helvetica.afm 消息),但并未解决底层的打包问题。我还尝试在 post-install 脚本中手动复制字体文件,但这增加了不必要的复杂性,仍然与 Vercel 的不可变构建缓存产生冲突。
Next.js 13+ 允许我们告知 serverless 编译器将某个包视为外部依赖,从而绕过默认的追踪逻辑。将其添加到 serverExternalPackages 也能解决 PDFKit 使用的 #imports 子路径映射解析问题。
// next.config.ts (before)
const nextConfig: NextConfig = {
// …other options
};
export default withSentryConfig(nextConfig);
// next.config.ts (after)
import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";
const nextConfig: NextConfig = {
// PDFKit reads its own files via #imports, so we keep it external.
serverExternalPackages: ["pdfkit"],
// Ensure the font files are bundled.
outputFileTracingIncludes: {
// Include both .afm and .cjs font resources.
"/api/reports/tv-issues": [
"node_modules/pdfkit/js/data/*.afm",
"node_modules/pdfkit/js/standard-fonts/*.cjs",
],
},
// …other Next.js options
};
export default withSentryConfig(nextConfig);
原理: 通过将 pdfkit 声明为外部包,Next.js 不再将其作为普通模块进行打包,而是将整个包(包括非 JS 资源)复制到 lambda 的 node_modules 中。outputFileTracingIncludes 条目明确告知追踪器包含那些本会被忽略的字体文件。
原始的 outputFileTracingIncludes 只捕获了 data/*.afm 文件。PDFKit 还附带了 standard-fonts/*.cjs,其中包含 PDF 渲染器使用的编译后字体定义。缺失这些文件导致了运行时崩溃。
- outputFileTracingIncludes: {
- "/api/reports/tv-issues": ["node_modules/pdfkit/js/data/*.afm"]
- },
+ outputFileTracingIncludes: {
+ "/api/reports/tv-issues": [
+ "node_modules/pdfkit/js/data/*.afm",
+ "node_modules/pdfkit/js/standard-fonts/*.cjs"
+ ]
+ },
项目使用严格的 TypeScript,导入 PDFKit 而没有类型会引发编译错误。将 @types/pdfkit 添加到 package.json 解决了这个问题。
// package.json (excerpt)
{
"dependencies": {
// …other deps
"@types/pdfkit": "^0.17.6"
}
}
lockfile(package-lock.json)会自动更新;无需代码改动。
打包问题解决后,路由可以专注于其核心职责:构建 PDF。我移除了临时的调试 console,只留下简洁的错误日志。
// src/app/api/reports/tv-issues/route.ts
import { PDFDocument } from "pdfkit";
import { getTvIssuesReport } from "@/services/tvIssuesReport.service";
export async function GET() {
try {
const report = await getTvIssuesReport();
const doc = new PDFDocument({ size: "A4", margin: 50 });
// ... draw headers, tables, footers ...
const chunks: Buffer[] = [];
doc.on("data", (chunk) => chunks.push(chunk));
doc.on("end", () => {
const pdfBuffer = Buffer.concat(chunks);
return new Response(pdfBuffer, {
status: 200,
headers: { "Content-Type": "application/pdf" },
});
});
// Trigger PDF generation
doc.end();
} catch (err) {
console.error("Error generando reporte PDF de TV:", err);
return new Response("PDF generation failed", { status: 500 });
}
}
运行 npm run dev 仍然像以前一样正常工作。关键的测试是 Vercel 预览构建:
vercel --prebuilt
预览 URL 正确返回了 PDF,确认字体文件已存在于 lambda bundle 中。
在 serverless 环境中使用加载非 JavaScript 资源(字体、模板等)的库时,务必将这些资源显式包含在 outputFileTracingIncludes 中,必要时将库标记为外部包。依赖默认追踪会静默丢弃必需文件,导致难以诊断的运行时 500 错误。
为 PDF 服务添加单元测试,使用 pdfkit 的内存 API 来捕获未来的回归问题。
在 CDN(Vercel Edge)中缓存生成的 PDF,以减少重复下载时的 lambda 冷启动延迟。
暴露一个流式端点,将 PDF 直接流式传输到客户端,而不是将整个文件缓存在内存中。
Roberto Luna Osorio – Full Stack Developer & Project Lead Playa del
本文是我 Build in Public 系列的一部分——分享从墨西哥卡波圣卢卡斯构建 SaaS 项目的真实过程。
Repo: zaerohell/tvview · 2026-09-11
#playadev #buildinpublic