import { fileURLToPath } from "node:url"; import { resolve } from "node:path"; import { performance } from "node:perf_hooks"; import Fastify from "fastify"; import { EXPORT_CONFIG_VERSION, createPagedDocumentPayload, defaultExportConfig, exportConfigSchema, supportedPaperFormats } from "@md-to-pdf/core"; import { ApplicationRequestError, createApplicationService, type ApplicationService } from "@md-to-pdf/application"; import { RENDERER_VERSION } from "@md-to-pdf/renderer"; import { createPdfGenerator, PdfEngineClosedError, PdfEngineOverloadedError, PdfRenderTimeoutError, type PdfGenerator } from "./pdf-engine.js"; interface RenderRequestBody { markdown?: unknown; language?: unknown; resources?: unknown; } interface PdfRequestBody extends RenderRequestBody { fileName?: unknown; exportConfig?: unknown; } const projectRoot = fileURLToPath(new URL("../../../", import.meta.url)); function encodeRfc5987(value: string) { return encodeURIComponent(value).replace( /[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}` ); } export function createPdfFileName(fileName: unknown) { const source = typeof fileName === "string" ? fileName.split(/[\\/]/).at(-1) ?? "" : ""; const stem = source .replace(/\.(?:md|markdown)$/i, "") .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") .replace(/[.\s]+$/g, "") .trim(); return `${stem || "文档"}.pdf`; } export function createPdfContentDisposition(fileName: string) { return ( `attachment; filename="document.pdf"; ` + `filename*=UTF-8''${encodeRfc5987(fileName)}` ); } export interface BuildAppOptions { localThemeRoot?: string; logger?: boolean; pdfGenerator?: PdfGenerator; prewarmPdfBrowser?: boolean; applicationService?: ApplicationService; } function milliseconds(value: number) { return Math.max(0, value).toFixed(1); } function createPdfServerTiming( themeMs: number, markdownMs: number, generated: Awaited>, requestMs: number ) { const { timings } = generated; return [ `theme;dur=${milliseconds(themeMs)}`, `markdown;dur=${milliseconds(markdownMs)}`, `queue;dur=${milliseconds(timings.queueMs)}`, `browser;dur=${milliseconds(timings.browserMs)}`, `context;dur=${milliseconds(timings.contextMs)}`, `navigation;dur=${milliseconds(timings.navigationMs)}`, `document-render;dur=${milliseconds(timings.documentRenderMs)}`, `runtime-setup;dur=${milliseconds(timings.runtimeSetupMs)}`, `echarts;dur=${milliseconds(timings.echartsMs)}`, `echarts-fit;dur=${milliseconds(timings.echartsFitMs)}`, `mermaid;dur=${milliseconds(timings.mermaidMs)}`, `mermaid-fit;dur=${milliseconds(timings.mermaidFitMs)}`, `mermaid-conversion;dur=${milliseconds( timings.mermaidConversionMs )}`, `resources;dur=${milliseconds(timings.resourceWaitMs)}`, `pagination;dur=${milliseconds(timings.paginationMs)}`, `pagination-finalize;dur=${milliseconds(timings.finalizeMs)}`, `pdf-print;dur=${milliseconds(timings.pdfPrintMs)}`, `total;dur=${milliseconds(requestMs)}` ].join(", "); } export function buildApp(options: BuildAppOptions = {}) { const app = Fastify({ logger: options.logger ?? true, bodyLimit: 24 * 1024 * 1024 }); const applicationService = options.applicationService ?? createApplicationService({ bundledRoot: resolve(projectRoot, "themes"), localRoot: options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"), onWarning: (message) => app.log.warn(message) }); const pdfGenerator = options.pdfGenerator ?? createPdfGenerator(); if (options.prewarmPdfBrowser) { app.addHook("onReady", async () => { try { await pdfGenerator.warmup?.(); } catch (error) { app.log.warn({ error }, "PDF browser warmup failed"); } }); } app.addHook("onClose", async () => { await pdfGenerator.close(); }); app.get("/api/health", async () => ({ status: "ok", service: "md-to-pdf", configVersion: EXPORT_CONFIG_VERSION, rendererVersion: RENDERER_VERSION })); app.get("/api/capabilities", async () => ({ defaultExportConfig, supportedPaperFormats, implemented: [ "project-skeleton", "export-config", "theme-manifest", "markdown-render", "html-preview", "pdf-export" ], planned: [] })); app.get("/api/themes", async () => applicationService.listThemes() ); app.get<{ Params: { themeId: string } }>( "/api/themes/:themeId/css", async (request, reply) => { const { themeId } = request.params; const css = await applicationService.getThemeCss(themeId); if (css === undefined) { return reply.code(404).send({ error: "THEME_NOT_FOUND", message: "未找到指定主题" }); } return reply .header("content-type", "text/css; charset=utf-8") .header("cache-control", "public, max-age=300") .send(css); } ); app.get<{ Params: { themeId: string; "*": string } }>( "/api/themes/:themeId/assets/*", async (request, reply) => { try { const asset = await applicationService.getThemeAsset( request.params.themeId, request.params["*"] ); if (!asset) { return reply.code(404).send({ error: "THEME_NOT_FOUND", message: "未找到指定主题" }); } return reply .header("content-type", asset.contentType) .header("cache-control", "public, max-age=300") .header("content-security-policy", "default-src 'none'; sandbox") .header("x-content-type-options", "nosniff") .send(asset.content); } catch { return reply.code(404).send({ error: "THEME_ASSET_NOT_FOUND", message: "未找到指定主题资源" }); } } ); app.post<{ Body: RenderRequestBody }>( "/api/render", async (request, reply) => { try { return await applicationService.render(request.body ?? {}); } catch (error) { if (error instanceof ApplicationRequestError) { return reply.code(error.statusCode).send({ error: error.code, message: error.message }); } throw error; } } ); app.post<{ Body: PdfRequestBody }>( "/api/pdf", async (request, reply) => { const requestStartedAt = performance.now(); const parsedConfig = exportConfigSchema.safeParse( request.body?.exportConfig ); if (!parsedConfig.success) { return reply.code(400).send({ error: "INVALID_EXPORT_CONFIG", message: "导出配置无效", issues: parsedConfig.error.issues }); } if ( request.body?.fileName !== undefined && (typeof request.body.fileName !== "string" || request.body.fileName.length > 500) ) { return reply.code(400).send({ error: "INVALID_FILE_NAME", message: "fileName 必须是长度不超过 500 的字符串" }); } const themeStartedAt = performance.now(); const themeCss = await applicationService.getThemeCss( parsedConfig.data.themeId ); const themeMs = performance.now() - themeStartedAt; if (themeCss === undefined) { return reply.code(404).send({ error: "THEME_NOT_FOUND", message: "未找到指定主题" }); } const markdownStartedAt = performance.now(); let rendered; try { rendered = await applicationService.render(request.body ?? {}); } catch (error) { if (error instanceof ApplicationRequestError) { return reply.code(error.statusCode).send({ error: error.code, message: error.message }); } throw error; } const markdownMs = performance.now() - markdownStartedAt; try { const generated = await pdfGenerator.generate( createPagedDocumentPayload({ document: rendered, fileName: typeof request.body.fileName === "string" ? request.body.fileName : "文档.md", themeCss, exportConfig: parsedConfig.data }) ); const pdfFileName = createPdfFileName(request.body.fileName); const requestMs = performance.now() - requestStartedAt; const serverTiming = createPdfServerTiming( themeMs, markdownMs, generated, requestMs ); request.log.info( { pageCount: generated.pageCount, timings: { themeMs, markdownMs, ...generated.timings, requestMs } }, "PDF generated" ); return reply .header("content-type", "application/pdf") .header( "content-disposition", createPdfContentDisposition(pdfFileName) ) .header("cache-control", "no-store") .header("server-timing", serverTiming) .header("x-pdf-page-count", String(generated.pageCount)) .header( "x-echarts-error-count", String(generated.echartsErrors.length) ) .header( "x-mermaid-error-count", String(generated.mermaidErrors.length) ) .send(generated.pdf); } catch (error) { if (error instanceof PdfEngineOverloadedError) { return reply .code(503) .header("retry-after", "5") .send({ error: "PDF_QUEUE_FULL", message: error.message }); } if (error instanceof PdfRenderTimeoutError) { return reply.code(504).send({ error: "PDF_RENDER_TIMEOUT", message: "PDF 生成超时,请缩短文档或稍后重试" }); } if (error instanceof PdfEngineClosedError) { return reply.code(503).send({ error: "PDF_ENGINE_UNAVAILABLE", message: "PDF 生成服务暂不可用" }); } request.log.error({ error }, "PDF generation failed"); return reply.code(500).send({ error: "PDF_GENERATION_FAILED", message: "PDF 生成失败" }); } } ); return app; }