026AI 工程阅读记录

基于 Open Design 的自定义发布二次开发实践

乌漆嘛黑和 Ahri
第 026 期

Open Design 已经能把一句需求变成可以预览的 HTML 页面,也提供了 Vercel 和 Cloudflare Pages 两种在线发布方式。但当运行环境变成一台家用 NAS,发布目标变成自己的域名、静态目录和导航首页时,内置 Provider 就不够用了。

最直接的想法是给 Open Design 塞入 SSH 私钥,再从主进程执行 rsync。这条路能跑,却把生成工具、服务器凭据和发布策略绑在了一起:Open Design 升级会影响发布,发布服务出问题也可能拖住主应用,SSH 权限边界更难控制。

最终采用的方案是给 Open Design 增加一个通用的 custom-webhook Provider,再单独部署一个静态发布服务。前者只负责整理文件并调用协议,后者负责鉴权、校验、落盘、回滚和对外提供静态资源。

本文记录这次二次开发的完整设计。实现基于 Open Design 0.15.1Codex CLI 0.144.6,时间是 2026 年 8 月。项目的接口和目录可能继续变化,但拆分边界、协议设计和原子发布方法并不依赖某个具体版本。

Preview
Open Design 自定义发布总体架构

成果预览

景德镇 · 婺源|6天5晚旅行手册

从需求边界开始

目标并不是再造一个 Vercel,而是解决一条很具体的自托管链路:

Open Design 生成静态页面
  -> 点击“部署”
  -> 推送到自己的 NAS
  -> 原子替换旧版本
  -> 返回公开访问地址
  -> 导航首页自动出现新站点

因此第一版明确不处理动态应用构建、数据库迁移、边缘函数和域名购买,只接收已经可以静态运行的 HTML、CSS、JavaScript、图片和字体。

为什么选择 Webhook,而不是 SSH

方案优点主要问题
Open Design 内置 SSH/rsync链路短,部署动作直观主应用持有 SSH 私钥,发布策略与 Open Design 升级强耦合
Open Design 调用部署脚本实现快脚本运行环境、日志、重试和权限不容易标准化
自定义 Webhook Provider协议稳定,接收端可独立升级,也容易替换需要设计请求协议并维护一个接收服务

Webhook 的关键价值不是“多一次 HTTP 请求”,而是把责任切开:

  • Open Design 知道哪些文件属于当前项目,但不需要知道 NAS 的目录结构。
  • 发布服务知道怎样安全写磁盘,但不需要访问 Open Design 的数据库和数据卷。
  • 反向代理只暴露一个受控路径,不需要把 Docker Socket 或 SSH 端口交给生成工具。
  • 将来若换成对象存储、Git 仓库或另一台服务器,只需实现相同协议。

在 Open Design 中增加 Provider

一个部署 Provider 不只是下拉框里的新选项。为了让 Web UI、HTTP API 和 CLI 行为一致,需要沿着共享契约、Daemon、路由、前端和测试逐层扩展。

Preview
custom-webhook Provider 的扩展层次

先定义共享契约

第一步是在前后端共享类型中加入 Provider ID、配置结构和传输协议。Provider 配置保留六个核心字段:

type DeployProviderId =
  | 'vercel-self'
  | 'cloudflare-pages'
  | 'custom-webhook';

interface CustomWebhookConfig {
  endpointUrl: string;
  headers: Record<string, string>;
  parameters: Record<string, JsonValue>;
  publicBaseUrl: string;
  responseUrlPath: string;
}

这些字段分别解决不同问题:

字段用途
endpointUrl接收发布文件包的 HTTP 地址
Bearer Token由 Open Design 生成 Authorization 请求头
headers附加非敏感、自定义请求头
parameters传递 visible、标题、标签和覆盖策略等业务参数
publicBaseUrl接收端没有返回 URL 时的兜底地址,可使用 {slug} 模板
responseUrlPath从响应 JSON 中取 URL,支持 urldata.url 这类点路径

配置的灵活性需要有边界。Daemon 只接受 HTTP/HTTPS 地址;自定义 Header 不能覆盖 HostContent-LengthAuthorization 等受控字段;responseUrlPath 只能使用简单点路径。这样既保留通用性,也避免配置表单变成任意请求注入器。

Provider 配置不能泄露 Token

部署配置保存在 Open Design 的运行数据目录,而不是当前 Shell 的 Home 目录。写入时使用 0600 权限,读取给前端时只返回固定掩码:

saved-custom-webhook-token

用户再次保存表单时,如果 Token 仍是这个掩码,Daemon 复用原值;只有输入新 Token 才会替换。这和常见云平台密钥设置页的行为一致,也避免浏览器拿到真实凭据。

让 HTTP API 成为统一入口

Provider 配置仍然复用 Open Design 原有接口:

PUT /api/deploy/config
Content-Type: application/json
{
  "providerId": "custom-webhook",
  "token": "<PUBLISH_TOKEN>",
  "customWebhook": {
    "endpointUrl": "https://example.com/static-deploy/api/v1/deploy",
    "headers": {},
    "parameters": {
      "visible": true,
      "overwrite": true
    },
    "publicBaseUrl": "https://example.com/static-deploy/projects/{slug}/",
    "responseUrlPath": "url"
  }
}

触发部署同样走项目文件接口:

POST /api/projects/:id/deploy
Content-Type: application/json
{
  "fileName": "index.html",
  "providerId": "custom-webhook",
  "target": "production"
}

Daemon 先用现有的 buildDeployFileSet 收集入口 HTML 及项目文件,再调用 deployToCustomWebhook。成功结果继续写入原有部署记录,因此文件预览页仍能显示部署次数、状态、地址和最近更新时间,不需要为自定义发布另建一套历史模型。

Web UI 和 CLI 保持同构

Web UI 的部署弹层增加“自定义部署服务”,包含服务地址、Token、额外 Header、参数 JSON、公开地址和响应 URL 路径。JSON 字段会在提交前解析,错误直接落在当前表单,而不是等到远端返回模糊的 400

同一能力也暴露给 CLI,适合 NAS 初始化或自动化任务:

od deploy config set \
  --provider custom-webhook \
  --endpoint-url "https://example.com/static-deploy/api/v1/deploy" \
  --token-file ./publish-token.txt \
  --public-base-url "https://example.com/static-deploy/projects/{slug}/" \
  --parameters-json '{"visible":true,"overwrite":true}'

od deploy run \
  --project <PROJECT_ID> \
  --file index.html \
  --provider custom-webhook \
  --json

这里有一个容易遗漏的工程量:新增 Provider 后,前端所有语言包和类型声明也必须补齐相同文案键。否则英语界面能用,切换语言后就可能出现原始 key 或类型错误。

设计一份可演进的发布协议

如果只把文件列表随手 POST 出去,接收端很快会依赖某次实现细节。协议因此显式加入版本号:

open-design.static-deploy.v1
Preview
发布协议的请求、落盘与响应时序

精简后的请求结构如下:

{
  "protocolVersion": "open-design.static-deploy.v1",
  "deploymentId": "550e8400-e29b-41d4-a716-446655440000",
  "projectId": "<PROJECT_ID>",
  "projectName": "旅行攻略示例",
  "fileName": "index.html",
  "target": "production",
  "slug": "travel-guide-index",
  "entryFile": "index.html",
  "files": [
    {
      "file": "index.html",
      "data": "PGh0bWw+Li4uPC9odG1sPg==",
      "encoding": "base64",
      "contentType": "text/html; charset=utf-8"
    }
  ],
  "parameters": {
    "visible": true,
    "title": "旅行攻略示例",
    "tags": ["travel"],
    "overwrite": true
  }
}

成功响应至少包含最终访问地址:

{
  "ok": true,
  "deploymentId": "550e8400-e29b-41d4-a716-446655440000",
  "target": "production",
  "status": "ready",
  "url": "https://example.com/static-deploy/projects/travel-guide-index/index.html"
}

Open Design 默认从 url 读取地址;如果第三方服务返回 { "data": { "url": "..." } },把 responseUrlPath 改成 data.url 即可。

文件内容采用 Base64,是为了让 JSON 协议足够简单并兼容二进制资源,代价是传输体积大约增加三分之一。当前请求使用 120 秒超时,更适合中小型静态站。超大图片、视频或数百 MB 产物应改用对象存储预签名地址或分片上传,而不是继续扩大 JSON 上限。

独立静态发布服务

接收端被拆成一个独立项目,使用 Node.js 24、ESM 和标准库,不依赖 Open Design 的代码或数据卷。它既能接收 Provider 主动推送,也保留从 Open Design API 主动拉取项目文件的兼容入口。

主要接口分成三类:

类型接口鉴权
推送发布POST /static-deploy/api/v1/deployPUBLISH_TOKEN
管理站点/static-deploy/api/sitesADMIN_TOKEN
公开访问/static-deploy/sites.json/static-deploy/projects/<slug>/

从服务自身看,管理与公开资源的原生路由分别是 /api/sites/sites.json/projects/<slug>/;表中的 /static-deploy 是通过 PUBLIC_PATH_PREFIX 统一加上的公网路径前缀。

visible=false 只表示不在 sites.json 导航数据中出现,并不会禁止静态地址访问。需要私有站点时,访问控制应该放在 Caddy、Nginx、Cloudflare Access 或 VPN 层,不能把“从列表隐藏”误当成权限系统。

双 Token 分离发布和管理权限

发布服务启动时强制检查:

  • ADMIN_TOKENPUBLISH_TOKEN 都必须配置;
  • 两者至少 32 字节;
  • 两个值不能相同;
  • 比较时使用 timingSafeEqual

Open Design 只持有 PUBLISH_TOKEN,因此它能发布文件,却不能删除站点、修改其他记录或调用管理接口。管理员浏览器只需要 ADMIN_TOKEN,两类凭据可以独立轮换。

所有路径先校验再落盘

接收文件包时,服务不会直接执行 path.join(publicRoot, input),而是经过 safeJoin 检查规范化后的绝对路径仍在目标根目录内。同时拒绝:

  • 空路径、重复路径和超过 500 字符的路径;
  • ../ 等目录穿越;
  • 非 Base64 内容和不存在的入口文件;
  • 超过文件数、单文件大小或总解码大小限制的请求。

默认上限为 2000 个文件、单文件 30 MiB、总内容 100 MiB。限制针对 Base64 解码后的真实字节数,避免用编码膨胀绕过配额。

同一 slug 串行,发布过程原子化

最隐蔽的问题不是单次发布失败,而是同一站点在短时间内被连续触发两次。如果两个请求同时移动目录和更新 sites.json,最终页面、元数据和返回地址可能来自不同版本。

Preview
同一 slug 的串行队列与原子发布事务

实现用了两层轻量队列:

  1. deploymentQueues 按 slug 串行化目录变更,不同站点仍可并行发布。
  2. siteMutationQueue 串行化所有 sites.json 更新,避免不同站点覆盖彼此的导航记录。

每次发布的事务顺序是:

校验并解码文件
  -> 写入 .staging/<slug>-<deploymentId>/
  -> 旧目录重命名为 backup
  -> staging 重命名为正式目录
  -> 原子更新 sites.json
  -> 删除 backup

如果正式目录切换后更新 sites.json 失败,服务会删除新目录并把 backup 恢复回来。临时目录和备份目录的清理放在 finally 中,并以尽力清理方式执行,避免一次清理失败反过来破坏已经完成的发布。

sites.json 自身也不是直接覆盖写入,而是先写同目录临时文件,再执行 rename。在同一文件系统中,目录和文件重命名都能提供我们需要的原子切换语义。

放进 NAS 的 Docker 配置

发布服务只需要一个持久数据卷。容器根文件系统设为只读,临时文件放进 tmpfs,并关闭新增权限:

name: open-design-static-deploy

services:
  static-deploy:
    image: open-design-static-deploy:latest
    container_name: open-design-static-deploy
    restart: unless-stopped
    ports:
      - "127.0.0.1:5200:5200"
    environment:
      ADMIN_TOKEN: "${ADMIN_TOKEN:?ADMIN_TOKEN required}"
      PUBLISH_TOKEN: "${PUBLISH_TOKEN:?PUBLISH_TOKEN required}"
      PUBLIC_BASE_URL: "${PUBLIC_BASE_URL:?PUBLIC_BASE_URL required}"
      PUBLIC_PATH_PREFIX: "/static-deploy"
      PUBLIC_ROOT: "/data/public"
      SITES_FILE: "/data/public/sites.json"
      STATIC_ASSET_MAX_AGE: "86400"
      MAX_DEPLOY_BYTES: "104857600"
      MAX_DEPLOY_FILES: "2000"
      MAX_DEPLOY_FILE_BYTES: "31457280"
    volumes:
      - static_publish_data:/data/public
    read_only: true
    tmpfs:
      - /tmp
    security_opt:
      - no-new-privileges:true
    mem_limit: "256m"
    pids_limit: 128

volumes:
  static_publish_data:

容器只绑定 127.0.0.1,公网入口交给 NAS 上已有的 Caddy。使用子路径时需要保留 /static-deploy 前缀:

example.com {
  @staticDeploy path /static-deploy /static-deploy/*
  handle @staticDeploy {
    reverse_proxy 127.0.0.1:5200
  }

  # 主站现有配置继续放在这里
}

如果 Caddy 也运行在 Docker 中,127.0.0.1 指向 Caddy 容器自身。此时应把两个容器加入同一 Docker 网络,并改用服务名,例如 reverse_proxy open-design-static-deploy:5200

缓存不能一刀切

生成站点的 HTML 可能随每次部署变化,而带 hash 的 JS、CSS 和图片更适合长缓存。服务按资源类型返回不同策略:

资源Cache-Control原因
sites.jsonno-store导航状态应立即反映管理操作
HTMLpublic, max-age=0, must-revalidate可以保存副本,但每次使用前确认是否更新
JS、CSS、图片、字体public, max-age=86400减少 NAS 和公网隧道的重复传输

这里的 max-age=0 不是“完全不缓存”,而是缓存立即过期、使用前必须重新验证。若生成资源没有内容 hash,也不应直接设置一年 immutable,否则覆盖发布后用户可能继续拿到旧文件。

验证真正重要的失败路径

这类发布功能不能只验证一次正常请求。Open Design 侧需要覆盖配置读写、Token 掩码、URL 和 Header 校验、协议字段、远端错误、响应 URL 提取、路由分派、部署记录以及 CLI 参数。

独立发布服务则至少要覆盖:

  • 健康检查、路径前缀和管理页;
  • 缺失、过短、重复 Token 时拒绝启动;
  • 发布 Token 与管理 Token 的 401 边界;
  • 多文件发布、站点列表分页和公开资源访问;
  • 同一 slug 并发发布后产物仍然完整;
  • HTML 与静态资源缓存头;
  • 非法 target、目录穿越和大小限制。

实际验证还应在目标 Node 24 Alpine 镜像中执行,而不只是在宿主机跑测试。这样可以同时发现文件系统、权限、只读根目录和 Node 版本差异。

这次二次开发留下的几个结论

第一,生成与发布应该是两个系统。Open Design 负责理解项目和收集产物,发布端负责环境凭据和最终落盘,双方通过有版本的协议协作。

第二,推送模式比接收端主动读取 Open Design 更容易封装。接收端不再需要长期保存 OD_API_TOKEN,Open Design 也能沿用自己的文件收集和部署记录能力。主动拉取可以保留为迁移兼容,但不应继续作为主路径。

第三,“复制文件”不是可靠发布。只有加入路径限制、资源配额、同目标串行、临时目录、原子重命名和失败回滚后,它才接近一个可以长期运行的发布服务。

第四,协议要给未来留出口,但第一版不要过度抽象parametersresponseUrlPath 提供了必要的适配能力;一旦进入超大产物、多阶段构建或跨区域分发,再升级到对象存储与异步任务,而不是继续堆叠当前同步 Webhook。

这样改造后,Open Design 不再只会把页面发到预设平台。它可以继续作为设计和代码生成入口,而真正的运行环境、域名、缓存、权限和发布策略仍掌握在自己的基础设施里。