单端口架构#
一个进程、一个端口,Python worker 是唯一源站。端口的配置入口与冲突 处理见 ports.zh.md。
1. 三进程带来的问题#
双端口运行时是三个进程组成的脆弱依赖链:
Electron 壳 → Next.js 服务(Node,web 端口) → Python worker(后端端口)
- Next 的
rewrites()在构建时固定/ws的代理目标。build 时环境变量 不对,/ws就指向死端口,所有客户端集体 "disconnected"。现有两个补丁 纯粹为绕开它:_patch_manifest_ports正则改写.next/routes-manifest.json(openprogram/worker/web.py),以及/api/[...path]route handler 每请求重读worker.port。 - worker 还要拉起并看护 Next 进程:
openprogram/worker/web.py约 330 行的 端口回收、孤儿next-server查杀、BUILD_ID 监视、manifest 补丁、父进程 PID 监视(apps/web/scripts/with-parent-watch.mjs)。 - 用户运行时必须装 Node,只为渲染一个本来就 100% 客户端的 UI。
2. 为什么合并很便宜#
前端没有任何东西需要 Node 服务:
- 应用壳用
next/dynamic+ssr: false加载 (apps/web/app/(shell)/layout.tsx),所有实际页面都是"use client"。 - 没有
middleware.ts、server actions、next/image,也没有output/basePath/headers等需要拆的配置。 - 仅有的两个 route handler(
app/api/[...path]、app/files/[...path]) 就是转发到 worker 的代理——单端口下 worker 本身就是源站。 - worker 的 FastAPI 已经在托管静态内容(
/docs文档站、/files/raw), 且docs_url=None,没有路由冲突。
3. 方案#
一个进程、一个端口,Python worker 托管一切:
Electron 壳 → Python worker(FastAPI,单端口)
├─ /ws 原生 WebSocket(index-0 路由)
├─ /api/* 原生 router
├─ /files/raw
├─ /docs/*
└─ /* Next 静态导出(out/)+ SPA 回退
3.1 前端是静态导出#
apps/web/next.config.mjs:
output: "export"→next build输出纯 HTML/JS/CSS 到apps/web/out/。- 没有
rewrites(),也没有resolveBackend()——不存在需要解析的代理目标。 - 前端只跟自己的源站说话(
/ws、/api/...相对路径)。
动态路由段((shell)/s/[sessionId]、(shell)/skills/[...name]、
(shell)/settings/providers/[providerId]、/plugin/<name>/<slug...>)都是
返回 null 或纯客户端从 pathname 解析参数的占位页。静态导出不带
generateStaticParams 会拒绝它们,因此这些 page 文件不存在;SPA 回退
(3.2)为这些路径返回壳页面,客户端路由处理其余部分。plugin 页面是
apps/web/app/plugin/page.tsx,从 pathname 解析名称和 slug。若某段真有服务端
逻辑,则保留它并加一个返回占位值的 generateStaticParams。
app/api/[...path]/route.ts 和 app/files/[...path]/route.ts 不存在;当前后端路由由
Python worker 提供。
3.2 worker 托管导出产物#
apps/server/openprogram_server/_webui/frontend.py,在 create_app() 最后挂载:
- 静态文件来自
apps/web/out/(/_next/static加 immutable 缓存头,HTML no-cache)。 - SPA 回退:任何未命中文件也未命中 API 路由的 GET,返回壳 HTML
(
out/chat.html——应用本来就/→/chat重定向,其余全靠pathname客户端解析)。 - 构建门:
apps/web/out/缺失或比apps/web/源码旧,就在启动时跑一次npm run build。Node 从此只是构建期依赖;打包发布版直接携带预构建的out/,运行时完全不碰 Node。
3.3 当前进程看护边界#
当前 worker 路径不会拉起或监视 Node 进程。活动入口是 openprogram/worker/runner.py,它调用
openprogram.webui.start_web 和 apps/server/openprogram_server/_webui/frontend.py:ensure_frontend_built。
旧的 openprogram/worker/web.py 与 apps/web/scripts/with-parent-watch.mjs 仍保留在 checkout
中,包含 start_web_frontend 等双端口代码,但当前 runner 不会调用它们;这些文件的清理另行决定。
3.4 端口语义#
后端端口就是唯一端口,web 端口相关旋钮退役:
| 双端口 | 单端口 | |
|---|---|---|
| stable | web 18100 / 后端 18109 | 18100 |
| dev | web 18200 / 后端 18209 | 18200 |
OPENPROGRAM_WEB_PORT与 UI 偏好web_port:过渡期作为后端端口的别名 接受(打警告日志),之后移除。worker.port文件:不变,仍是端口发现的唯一权威。- Electron
apps/desktop/main.js:WEB_PORT常量(dev 18200 / 发布 18100) 现在就是 worker 端口;三处使用(启动 URL、origin 校验、导航守卫)无需 结构性改动。 scripts/promote_stable.sh:npm run build输出out/。
4. 不变量#
- 后端是唯一源站。 没有代理层、没有第二个服务、任何地方都不在构建时
固定端口。
/ws目标天然正确,因为它就是页面加载来源。 - API 路由永远优先于静态。 前端挂载注册在最后,SPA 回退只处理没有 任何 router 认领的路径。
- Node 只在构建期。 运行时依赖是 Python 加 worker。
5. 取舍#
- 开发迭代失去
next devHMR 对合并源站的直连。npm run dev仍可用—— ws/api 客户端工具在设置了 dev 专用环境变量 (NEXT_PUBLIC_BACKEND_ORIGIN)时指向运行中的 worker;生产代码路径仍是 同源相对路径。 - 真渲染内容的动态段,在其 page 文件不存在后深链会 404。由 SPA 回退兜底, 四个段逐一核实。
- 拉取前端改动后
out/可能过期。启动时的构建门(mtime 检查)覆盖;拉代码 后openprogram restart是既定工作流。
6. 验收标准#
openprogram(dev profile)启动后只监听一个端口;lsof里没有next-server。- 直接刷新
/chat、/s/<id>、/settings/providers/<id>、/skills/<name>全部能渲染;/ws连通;/api/pick-folder可用。 - 杀掉 worker:已加载页面显示 disconnected;重启后重连。任何端口上都没有 孤儿进程。
- 不带任何 profile 环境变量做一次 build,实例照样工作——构建时固定 端口这一整类故障从结构上消失。
- 全量测试通过;桌面应用重新打包并验证。
分发边界#
单端口让 Node 成为构建期依赖,并允许 release 携带预构建前端。安装与打包的 规范来源是安装、打包、发布与升级设计。 macOS 桌面应用把 CPython 和核心依赖放入签名 app bundle,不在首次启动时下载 基础 Python runtime。CLI/server 安装使用 uv 管理的 Python 环境,source checkout 保留开发构建流程。
附录:实现状态#
当前 runner 和 frontend mount 已实现单端口设计。旧双端口 manager 仍保留在 checkout 中,但不在活动 调用路径上。桌面监管和分发状态由上面链接的安装与打包设计记录。