Files
MorphDoc/apps/server/src/app.ts
T

369 lines
10 KiB
TypeScript

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<ReturnType<PdfGenerator["generate"]>>,
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;
}