feat: 实现 AIO 容器部署

This commit is contained in:
SkyJourney
2026-07-26 16:55:36 +08:00
parent d1b5880a5b
commit 15dd2acfc0
13 changed files with 425 additions and 32 deletions
+1
View File
@@ -0,0 +1 @@
deploy/entrypoint.sh text eol=lf
+1
View File
@@ -12,4 +12,5 @@ Thumbs.db
tmp/ tmp/
playwright-report/ playwright-report/
test-results/ test-results/
output/
*.tsbuildinfo *.tsbuildinfo
+15 -3
View File
@@ -13,11 +13,12 @@
- Markdown、Front Matter、安全过滤和扩展语法渲染核心; - Markdown、Front Matter、安全过滤和扩展语法渲染核心;
- 表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid - 表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid
- iframe 隔离的真实分页预览、双向滚动同步与主题切换; - iframe 隔离的真实分页预览、双向滚动同步与主题切换;
- Chromium PDF 下载、精确 PDF.js 预览与导出性能观测;
- 五种纸张、页边距、页眉、页码和浏览器配置缓存; - 五种纸张、页边距、页眉、页码和浏览器配置缓存;
- 内置 Typora 风格主题,以及仅供本机使用的三套白色 Typora 默认主题导入工具; - 内置 Typora 风格主题,以及仅供本机使用的三套白色 Typora 默认主题导入工具;
- Docker 与 PDF 渲染目录预留 - 前端、Fastify、Nginx 和 Playwright Chromium 的 AIO 容器部署
Chromium PDF 下载和本地资源文件处理将在后续阶段实现。 本地相对资源文件处理将在后续阶段实现。
## 本地开发 ## 本地开发
@@ -96,6 +97,16 @@ npm run theme:import-typora -- --replace
自定义主题目录、`theme.json`、CSS 组合顺序、资源引用和安全限制详见 [主题开发指南](docs/THEMES.md)。 自定义主题目录、`theme.json`、CSS 组合顺序、资源引用和安全限制详见 [主题开发指南](docs/THEMES.md)。
## Docker Desktop 部署
```powershell
docker compose -f deploy\compose.yaml up --build -d
```
默认访问地址为 http://localhost:8080。Compose 会将 `.local/themes`
作为只读主题目录挂载;端口、主题目录和 PDF 并发参数的配置方式详见
[AIO 容器部署说明](deploy/README.md)。
## 测试与构建 ## 测试与构建
```powershell ```powershell
@@ -107,4 +118,5 @@ git diff --check
## 隐私原则 ## 隐私原则
文档和资源文件仅用于当前转换请求。PDF 阶段将为每次请求创建隔离的临时目录,并在成功或失败后统一清理,不接入数据库或文档历史存储。 文档内容仅用于当前渲染和转换请求,不接入数据库或文档历史存储。
相对资源文件支持将在后续阶段通过隔离临时目录和请求结束清理实现。
+57 -3
View File
@@ -56,6 +56,12 @@ export interface PdfEngineOptions {
launchBrowser?: () => Promise<Browser>; launchBrowser?: () => Promise<Browser>;
} }
export interface PdfEngineRuntimeLimits {
concurrency: number;
maxQueue: number;
timeoutMs: number;
}
interface PagedRuntimeResult { interface PagedRuntimeResult {
pageCount: number; pageCount: number;
contentHeight: number; contentHeight: number;
@@ -168,6 +174,53 @@ function normalizeRenderOrigin(value: string) {
return url.origin; return url.origin;
} }
function readIntegerEnvironment(
environment: NodeJS.ProcessEnv,
name: string,
fallback: number,
minimum: number
) {
const rawValue = environment[name];
if (rawValue === undefined || rawValue.trim() === "") {
return fallback;
}
if (!/^\d+$/.test(rawValue)) {
throw new Error(`${name} 必须是整数`);
}
const value = Number.parseInt(rawValue, 10);
if (!Number.isSafeInteger(value) || value < minimum) {
throw new Error(`${name} 不能小于 ${minimum}`);
}
return value;
}
export function readPdfEngineRuntimeLimits(
environment: NodeJS.ProcessEnv = process.env
): PdfEngineRuntimeLimits {
return {
concurrency: readIntegerEnvironment(
environment,
"PDF_CONCURRENCY",
2,
1
),
maxQueue: readIntegerEnvironment(
environment,
"PDF_MAX_QUEUE",
8,
0
),
timeoutMs: readIntegerEnvironment(
environment,
"PDF_TIMEOUT_MS",
60_000,
1_000
)
};
}
export function isAllowedPdfRequestUrl( export function isAllowedPdfRequestUrl(
requestUrl: string, requestUrl: string,
renderOrigin: string renderOrigin: string
@@ -189,6 +242,7 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
private closed = false; private closed = false;
constructor(options: PdfEngineOptions = {}) { constructor(options: PdfEngineOptions = {}) {
const runtimeLimits = readPdfEngineRuntimeLimits();
this.renderOrigin = normalizeRenderOrigin( this.renderOrigin = normalizeRenderOrigin(
options.renderOrigin ?? options.renderOrigin ??
process.env.PDF_RENDER_ORIGIN ?? process.env.PDF_RENDER_ORIGIN ??
@@ -198,10 +252,10 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
"/preview-frame.html?target=pdf", "/preview-frame.html?target=pdf",
this.renderOrigin this.renderOrigin
).href; ).href;
this.timeoutMs = options.timeoutMs ?? 60_000; this.timeoutMs = options.timeoutMs ?? runtimeLimits.timeoutMs;
this.gate = new ConcurrencyGate( this.gate = new ConcurrencyGate(
options.concurrency ?? 2, options.concurrency ?? runtimeLimits.concurrency,
options.maxQueue ?? 8 options.maxQueue ?? runtimeLimits.maxQueue
); );
this.launchBrowser = this.launchBrowser =
options.launchBrowser ?? options.launchBrowser ??
+36
View File
@@ -8,6 +8,7 @@ import {
PdfRenderTimeoutError, PdfRenderTimeoutError,
PlaywrightPdfGenerator, PlaywrightPdfGenerator,
isAllowedPdfRequestUrl, isAllowedPdfRequestUrl,
readPdfEngineRuntimeLimits,
type PdfRenderPayload type PdfRenderPayload
} from "../src/pdf-engine.js"; } from "../src/pdf-engine.js";
@@ -102,6 +103,41 @@ describe("PDF 并发控制", () => {
}); });
}); });
describe("PDF 运行参数", () => {
it("读取容器环境变量", () => {
expect(
readPdfEngineRuntimeLimits({
PDF_CONCURRENCY: "1",
PDF_MAX_QUEUE: "4",
PDF_TIMEOUT_MS: "120000"
})
).toEqual({
concurrency: 1,
maxQueue: 4,
timeoutMs: 120_000
});
});
it("缺少环境变量时使用默认值", () => {
expect(readPdfEngineRuntimeLimits({})).toEqual({
concurrency: 2,
maxQueue: 8,
timeoutMs: 60_000
});
});
it.each([
["PDF_CONCURRENCY", "0"],
["PDF_MAX_QUEUE", "-1"],
["PDF_TIMEOUT_MS", "999"],
["PDF_CONCURRENCY", "1.5"]
])("拒绝无效的 %s", (name, value) => {
expect(() =>
readPdfEngineRuntimeLimits({ [name]: value })
).toThrow(name);
});
});
describe("PDF 网络策略", () => { describe("PDF 网络策略", () => {
it("只允许渲染同源和内嵌资源", () => { it("只允许渲染同源和内嵌资源", () => {
const origin = "http://127.0.0.1:5173"; const origin = "http://127.0.0.1:5173";
+77
View File
@@ -0,0 +1,77 @@
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS build
WORKDIR /app
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
COPY package.json package-lock.json ./
COPY apps/server/package.json apps/server/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/core/package.json packages/core/package.json
COPY packages/renderer/package.json packages/renderer/package.json
RUN npm ci
COPY tsconfig.base.json ./
COPY apps ./apps
COPY packages ./packages
RUN npm run build
FROM node:22-bookworm-slim AS production-dependencies
WORKDIR /app
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
COPY package.json package-lock.json ./
COPY apps/server/package.json apps/server/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/core/package.json packages/core/package.json
COPY packages/renderer/package.json packages/renderer/package.json
RUN npm ci --omit=dev --workspace @md-to-pdf/server --include-workspace-root
FROM node:22-bookworm-slim AS runtime
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
nginx \
fonts-noto-cjk \
fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system pwuser \
&& useradd --system --create-home --gid pwuser --shell /bin/bash pwuser
WORKDIR /app
COPY --chown=pwuser:pwuser package.json package-lock.json ./
COPY --chown=pwuser:pwuser apps/server/package.json apps/server/package.json
COPY --chown=pwuser:pwuser apps/web/package.json apps/web/package.json
COPY --chown=pwuser:pwuser packages/core/package.json packages/core/package.json
COPY --chown=pwuser:pwuser packages/renderer/package.json packages/renderer/package.json
COPY --chown=pwuser:pwuser --from=production-dependencies /app/node_modules ./node_modules
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
RUN npx playwright install --with-deps chromium \
&& chown -R pwuser:pwuser /ms-playwright
COPY --chown=pwuser:pwuser --from=build /app/apps/server/dist ./apps/server/dist
COPY --chown=pwuser:pwuser --from=build /app/apps/web/dist ./apps/web/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/core/dist ./packages/core/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/renderer/dist ./packages/renderer/dist
COPY --chown=pwuser:pwuser themes ./themes
COPY deploy/nginx.conf /etc/nginx/nginx.conf
COPY --chown=pwuser:pwuser deploy/entrypoint.sh /app/deploy/entrypoint.sh
RUN chmod 0755 /app/deploy/entrypoint.sh
ENV HOST=127.0.0.1 \
PORT=3001 \
PDF_RENDER_ORIGIN=http://127.0.0.1:8080 \
PDF_CONCURRENCY=1 \
PDF_MAX_QUEUE=4 \
PDF_TIMEOUT_MS=120000
USER pwuser
EXPOSE 8080
ENTRYPOINT ["/app/deploy/entrypoint.sh"]
+13
View File
@@ -0,0 +1,13 @@
.git
.github
.local
.vscode
coverage
dist
node_modules
output
playwright-report
test-results
tmp
*.log
*.tsbuildinfo
+76
View File
@@ -0,0 +1,76 @@
# AIO 容器部署
本目录提供单容器部署:Nginx 在 `8080` 端口提供前端,并将
`/api/` 代理到同容器内的 Fastify;Fastify 复用固定版本
Playwright Chromium 生成 PDF。
## 启动
要求已经安装并启动 Docker Desktop。
```powershell
docker compose -f deploy\compose.yaml up --build -d
docker compose -f deploy\compose.yaml ps
```
打开:
```text
http://localhost:8080
```
健康检查:
```powershell
Invoke-RestMethod -Uri 'http://localhost:8080/api/health'
```
查看日志和停止:
```powershell
docker compose -f deploy\compose.yaml logs -f
docker compose -f deploy\compose.yaml down
```
## 构建发布标签
本地验证完成后,可以为镜像增加发布标签:
```powershell
docker tag md-to-pdf:local yixiong/md-to-pdf:v0.1.0
```
该命令只创建本地标签,不会自动推送到镜像仓库。
## 主题挂载
Compose 默认将项目的 `.local/themes` 只读挂载到容器中的
`/app/.local/themes`。可以通过环境变量指定其他目录:
```powershell
$env:MD_TO_PDF_THEME_DIR = 'D:\md-to-pdf-themes'
docker compose -f deploy\compose.yaml up -d
```
目录下每套主题都应包含 `theme.json`。主题规范详见
[`docs/THEMES.md`](../docs/THEMES.md)。
本机通过导入工具生成的 Typora 主题不会复制进镜像;只有显式挂载后,
容器才会读取这些本机文件。
## 运行参数
| 环境变量 | 默认值 | 说明 |
| --- | ---: | --- |
| `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 |
| `MD_TO_PDF_THEME_DIR` | `../.local/themes` | 宿主机主题目录 |
| `PDF_CONCURRENCY` | `1` | 同时执行的 PDF 任务数 |
| `PDF_MAX_QUEUE` | `4` | 等待队列上限 |
| `PDF_TIMEOUT_MS` | `120000` | 单次 PDF 生成超时 |
增加 PDF 并发会显著增加 Chromium 内存占用。单机部署建议先保持默认值,
再根据文档复杂度和可用内存调整。
容器为 Chromium 提供 1 GiB 共享内存,并以非 root `pwuser` 运行。
构建阶段使用项目锁定的 Playwright `1.62.0` 安装精确匹配的 Chromium
及系统依赖,不依赖版本可能滞后的预制浏览器镜像标签。
+35
View File
@@ -0,0 +1,35 @@
name: md-to-pdf
services:
app:
build:
context: ..
dockerfile: deploy/Dockerfile
image: md-to-pdf:local
init: true
ports:
- "${MD_TO_PDF_PORT:-8080}:8080"
environment:
HOST: 127.0.0.1
PORT: 3001
PDF_RENDER_ORIGIN: http://127.0.0.1:8080
PDF_CONCURRENCY: "${PDF_CONCURRENCY:-1}"
PDF_MAX_QUEUE: "${PDF_MAX_QUEUE:-4}"
PDF_TIMEOUT_MS: "${PDF_TIMEOUT_MS:-120000}"
volumes:
- "${MD_TO_PDF_THEME_DIR:-../.local/themes}:/app/.local/themes:ro"
shm_size: "1gb"
security_opt:
- no-new-privileges:true
restart: unless-stopped
stop_grace_period: 30s
healthcheck:
test:
- CMD
- node
- -e
- fetch('http://127.0.0.1:8080/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 10s
timeout: 3s
retries: 6
start_period: 30s
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -Eeuo pipefail
server_pid=""
nginx_pid=""
terminate() {
trap - TERM INT
if [[ -n "${nginx_pid}" ]]; then
kill -TERM "${nginx_pid}" 2>/dev/null || true
fi
if [[ -n "${server_pid}" ]]; then
kill -TERM "${server_pid}" 2>/dev/null || true
fi
}
trap terminate TERM INT
node /app/apps/server/dist/index.js &
server_pid=$!
nginx -g "daemon off;" &
nginx_pid=$!
set +e
wait -n "${server_pid}" "${nginx_pid}"
status=$?
set -e
terminate
wait "${server_pid}" 2>/dev/null || true
wait "${nginx_pid}" 2>/dev/null || true
exit "${status}"
+50
View File
@@ -0,0 +1,50 @@
worker_processes auto;
pid /tmp/nginx.pid;
error_log /dev/stderr warn;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
types {
application/javascript mjs;
}
default_type application/octet-stream;
access_log /dev/stdout;
sendfile on;
keepalive_timeout 65;
client_max_body_size 3m;
client_body_temp_path /tmp/nginx-client-body;
proxy_temp_path /tmp/nginx-proxy;
fastcgi_temp_path /tmp/nginx-fastcgi;
uwsgi_temp_path /tmp/nginx-uwsgi;
scgi_temp_path /tmp/nginx-scgi;
server {
listen 8080;
server_name _;
root /app/apps/web/dist;
location /api/ {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 130s;
proxy_send_timeout 130s;
proxy_buffering off;
}
location / {
try_files $uri $uri/ /index.html;
}
}
}
-12
View File
@@ -1,12 +0,0 @@
# Docker
本目录将在 PDF 渲染阶段加入基于固定版本 Chromium 的容器配置。
计划包括:
- 非 root 运行用户;
- Chromium 运行依赖;
- 临时目录容量限制;
- 健康检查;
- Docker Compose 内网部署;
- 请求结束后的临时资源清理。
+31 -14
View File
@@ -19,6 +19,7 @@ main
已有提交: 已有提交:
```text ```text
d1b5880 feat: 实现 Chromium PDF 导出与精确预览
d625523 fix: 修复长文档分页与滚动同步 d625523 fix: 修复长文档分页与滚动同步
1d93f31 feat: 实现真实分页预览 1d93f31 feat: 实现真实分页预览
52cf816 feat: 完善导出设置与打印预览 52cf816 feat: 完善导出设置与打印预览
@@ -177,6 +178,18 @@ ec48bce chore: 初始化项目骨架
- 65 页附件的主要耗时来自 Mermaid 渲染、Paged.js 分页和 Chromium PDF 打印。 - 65 页附件的主要耗时来自 Mermaid 渲染、Paged.js 分页和 Chromium PDF 打印。
- PDF 缓冲区直接返回,避免无意义的二次 `Buffer` 复制。 - PDF 缓冲区直接返回,避免无意义的二次 `Buffer` 复制。
### 3.5 AIO 容器部署
- 使用多阶段 Dockerfile 构建前端、后端和共享包。
- 基于 Node.js 22 Debian 构建运行镜像,并由项目锁定的 Playwright
`1.62.0` 安装精确匹配的 Chromium 与系统依赖。
- 单容器内由 Nginx 提供前端并代理 Fastify API。
- Playwright 通过同一 Nginx 地址加载精确预览运行时。
- 容器以非 root `pwuser` 运行并提供 1 GiB 共享内存。
- Compose 支持端口、只读主题目录、PDF 并发、队列和超时配置。
- 增加容器健康检查和统一进程退出处理。
- Nginx 显式以 JavaScript MIME 类型提供 PDF.js `.mjs` Worker。
## 4. 已执行验证 ## 4. 已执行验证
2026-07-26 在当前完整工作区成功执行: 2026-07-26 在当前完整工作区成功执行:
@@ -218,11 +231,26 @@ git diff --check
- KaTeX、代码高亮和 Mermaid 正常显示; - KaTeX、代码高亮和 Mermaid 正常显示;
- 浏览器控制台无警告或错误。 - 浏览器控制台无警告或错误。
为方便用户继续检查,开发服务当前仍在后台运行,前端监听 5173,后端监听 3001。 2026-07-26 已在 Docker Desktop 完成 AIO 容器验证:
- Docker Compose 配置展开、镜像构建、启动、健康检查和重启恢复通过;
- 运行镜像约 611 MBNode.js、Nginx、Fastify 和 Chromium 均以非
root UID 999 运行;
- Nginx 首页、分页运行页、API、主题 CSS 和 PDF.js Worker 均正常;
- 默认只读挂载识别 1 个内置主题和 3 个本地主题;
- 周报通过容器导出为 4 页,附件通过容器导出为 65 页,Mermaid
错误数均为 0
- 两份 PDF 的所有页面均包含可搜索文字,附件共提取 68,434 个字符;
- 抽查周报第 1、4 页和附件第 1、33、65 页,未发现裁切、重叠、
黑块或空白页;
- 浏览器快速预览、主题切换、精确 PDF.js 预览和导出交互通过;
- 修复 Nginx 缺少 `.mjs` MIME 映射导致精确预览 Worker 加载失败的问题。
为方便用户继续检查,AIO 容器当前运行在 `http://localhost:8080`
## 5. 当前注意事项 ## 5. 当前注意事项
- `output/` 可能包含本地 PDF 验证产物,不得提交。 - `output/` 可能包含本地 PDF 验证产物,已被 Git 忽略,不得提交。
- `apps/web/src/App.tsx` 负责分页 iframe 生命周期和父页面消息处理。 - `apps/web/src/App.tsx` 负责分页 iframe 生命周期和父页面消息处理。
- `apps/web/src/paged-document-runtime.ts` 是快速预览和 PDF 共用的 Mermaid、资源等待和 Paged.js 分页运行时。 - `apps/web/src/paged-document-runtime.ts` 是快速预览和 PDF 共用的 Mermaid、资源等待和 Paged.js 分页运行时。
- `apps/web/src/paged-preview-frame.ts` 负责 iframe 消息协议和运行目标选择。 - `apps/web/src/paged-preview-frame.ts` 负责 iframe 消息协议和运行目标选择。
@@ -241,7 +269,7 @@ git diff --check
- `.local/theme-backups` 中保留 GitHub、Pixyll 和 Whitey 的可恢复备份;Newsprint 和 Night 的当前副本及备份已按用户要求删除。 - `.local/theme-backups` 中保留 GitHub、Pixyll 和 Whitey 的可恢复备份;Newsprint 和 Night 的当前副本及备份已按用户要求删除。
- Typora 官方资源是 All Rights Reserved,仅限当前用户本机使用,不进入 Git、容器或发布包。 - Typora 官方资源是 All Rights Reserved,仅限当前用户本机使用,不进入 Git、容器或发布包。
- 资源目录上传、本地相对图片、临时目录和路径安全尚未实现。 - 资源目录上传、本地相对图片、临时目录和路径安全尚未实现。
- Dockerfile 和 Compose 尚未实现 - AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证
## 6. 推荐接手顺序 ## 6. 推荐接手顺序
@@ -260,17 +288,6 @@ git diff --check
- 浏览器本地配置预设; - 浏览器本地配置预设;
- 配置 JSON 导入和导出。 - 配置 JSON 导入和导出。
### 阶段三:容器化与交付
- Dockerfile
- Docker Compose
- Chromium 字体与中文字体;
- 非 root 用户;
- 健康检查;
- 临时目录容量限制;
- 内网部署说明;
- 完整 PDF 样例和视觉回归验证。
## 7. 首版验收目标 ## 7. 首版验收目标
- 可以选择或粘贴 Markdown - 可以选择或粘贴 Markdown