Open Design 已经能把一句需求变成可以预览的 HTML 页面,也提供了 Vercel 和 Cloudflare Pages 两种在线发布方式。但当运行环境变成一台家用 NAS,发布目标变成自己的域名、静态目录和导航首页时,内置 Provider 就不够用了。
最直接的想法是给 Open Design 塞入 SSH 私钥,再从主进程执行 rsync。这条路能跑,却把生成工具、服务器凭据和发布策略绑在了一起:Open Design 升级会影响发布,发布服务出问题也可能拖住主应用,SSH 权限边界更难控制。
最终采用的方案是给 Open Design 增加一个通用的 custom-webhook Provider,再单独部署一个静态发布服务。前者只负责整理文件并调用协议,后者负责鉴权、校验、落盘、回滚和对外提供静态资源。
本文记录这次二次开发的完整设计。实现基于 Open Design 0.15.1 和 Codex CLI 0.144.6,时间是 2026 年 8 月。项目的接口和目录可能继续变化,但拆分边界、协议设计和原子发布方法并不依赖某个具体版本。
成果预览
从需求边界开始
目标并不是再造一个 Vercel,而是解决一条很具体的自托管链路:
因此第一版明确不处理动态应用构建、数据库迁移、边缘函数和域名购买,只接收已经可以静态运行的 HTML、CSS、JavaScript、图片和字体。
为什么选择 Webhook,而不是 SSH
Webhook 的关键价值不是“多一次 HTTP 请求”,而是把责任切开:
- Open Design 知道哪些文件属于当前项目,但不需要知道 NAS 的目录结构。
- 发布服务知道怎样安全写磁盘,但不需要访问 Open Design 的数据库和数据卷。
- 反向代理只暴露一个受控路径,不需要把 Docker Socket 或 SSH 端口交给生成工具。
- 将来若换成对象存储、Git 仓库或另一台服务器,只需实现相同协议。
在 Open Design 中增加 Provider
一个部署 Provider 不只是下拉框里的新选项。为了让 Web UI、HTTP API 和 CLI 行为一致,需要沿着共享契约、Daemon、路由、前端和测试逐层扩展。
先定义共享契约
第一步是在前后端共享类型中加入 Provider ID、配置结构和传输协议。Provider 配置保留六个核心字段:
这些字段分别解决不同问题:
配置的灵活性需要有边界。Daemon 只接受 HTTP/HTTPS 地址;自定义 Header 不能覆盖 Host、Content-Length、Authorization 等受控字段;responseUrlPath 只能使用简单点路径。这样既保留通用性,也避免配置表单变成任意请求注入器。
Provider 配置不能泄露 Token
部署配置保存在 Open Design 的运行数据目录,而不是当前 Shell 的 Home 目录。写入时使用 0600 权限,读取给前端时只返回固定掩码:
用户再次保存表单时,如果 Token 仍是这个掩码,Daemon 复用原值;只有输入新 Token 才会替换。这和常见云平台密钥设置页的行为一致,也避免浏览器拿到真实凭据。
让 HTTP API 成为统一入口
Provider 配置仍然复用 Open Design 原有接口:
触发部署同样走项目文件接口:
Daemon 先用现有的 buildDeployFileSet 收集入口 HTML 及项目文件,再调用 deployToCustomWebhook。成功结果继续写入原有部署记录,因此文件预览页仍能显示部署次数、状态、地址和最近更新时间,不需要为自定义发布另建一套历史模型。
Web UI 和 CLI 保持同构
Web UI 的部署弹层增加“自定义部署服务”,包含服务地址、Token、额外 Header、参数 JSON、公开地址和响应 URL 路径。JSON 字段会在提交前解析,错误直接落在当前表单,而不是等到远端返回模糊的 400。
同一能力也暴露给 CLI,适合 NAS 初始化或自动化任务:
这里有一个容易遗漏的工程量:新增 Provider 后,前端所有语言包和类型声明也必须补齐相同文案键。否则英语界面能用,切换语言后就可能出现原始 key 或类型错误。
设计一份可演进的发布协议
如果只把文件列表随手 POST 出去,接收端很快会依赖某 次实现细节。协议因此显式加入版本号:
精简后的请求结构如下:
成功响应至少包含最终访问地址:
Open Design 默认从 url 读取地址;如果第三方服务返回 { "data": { "url": "..." } },把 responseUrlPath 改成 data.url 即可。
文件内容采用 Base64,是为了让 JSON 协议足够简单并兼容二进制资源,代价是传输体积大约增加三分之一。当前请求使用 120 秒超时,更适合中小型静态站。超大图片、视频或数百 MB 产物应改用对象存储预签名地址或分片上传,而不是继续扩大 JSON 上限。
独立静态发布服务
接收端被拆成一个独立项目,使用 Node.js 24、ESM 和标准库,不依赖 Open Design 的代码或数据卷。它既能接收 Provider 主动推送,也保留从 Open Design API 主动拉取项目文件的兼容入口。
主要接口分成三类:
从服务自身看,管理与公开资源的原生路由分别是 /api/sites、/sites.json 和 /projects/<slug>/;表中的 /static-deploy 是通过 PUBLIC_PATH_PREFIX 统一加上的公网路径前缀。
visible=false 只表 示不在 sites.json 导航数据中出现,并不会禁止静态地址访问。需要私有站点时,访问控制应该放在 Caddy、Nginx、Cloudflare Access 或 VPN 层,不能把“从列表隐藏”误当成权限系统。
双 Token 分离发布和管理权限
发布服务启动时强制检查:
ADMIN_TOKEN和PUBLISH_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,最终页面、元数据和返回地址可能来自不同版本。
实现用了两层轻量队列:
deploymentQueues按 slug 串行化目录变更,不同站点仍可并行发布。siteMutationQueue串行化所有sites.json更新,避免不同站点覆盖彼此的导航记录。
每次发布的事务顺序是:
如果正式目录切换后更新 sites.json 失败,服务会删除新目录并把 backup 恢复回来。临时目录和备份目录的清理放在 finally 中,并以尽力清理方式执行,避免一次清理失败反过来破坏已经完成的发布。
sites.json 自身也不是直接覆盖写入,而是先写同目录临时文件,再执行 rename。在同一文件系统中,目录和文件重命名都能提供我们需要的原子切换语义。
放进 NAS 的 Docker 配置
发布服务只需要一个持久数据卷。容器根文件系统设为只读,临时文件放进 tmpfs,并关闭新增权限:
容器只绑定 127.0.0.1,公网入口交给 NAS 上已有的 Caddy。使用子路径时需要保留 /static-deploy 前缀:
如果 Caddy 也运行在 Docker 中,127.0.0.1 指向 Caddy 容器自身。此时应把两个容器加入同一 Docker 网络,并改用服务名,例如 reverse_proxy open-design-static-deploy:5200。
缓存不能一刀切
生成站点的 HTML 可能随每次部署变化,而带 hash 的 JS、CSS 和图片更适合长缓存。服务按资源类型返回不同策略:
这里的 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 也能沿用自己的文件收集和部署记录能力。主动拉取可以保留为迁移兼容,但不应继续作为主路径。
第三,“复制文件”不是可靠发布。只有加入路径限制、资源配额、同目标串行、临时目录、原子重命名和失败回滚后,它才接近一个可以长期运行的发布服务。
第四,协议要给未来留出口,但第一版不要过度抽象。parameters 和 responseUrlPath 提供了必要的适配能力;一旦进入超大产物、多阶段构建或跨区域分发,再升级到对象存储与异步任务,而不是继续堆叠当前同步 Webhook。
这样改造后,Open Design 不再只会把页面发到预设平台。它可以继续作为设计和代码生成入口,而真正的运行环境、域名、缓存、权限和发布策略仍掌握在自己的基础设施里。
