Single-Port Architecture#
One process, one port, the Python worker as sole origin. The port's configuration surface and conflict handling live in ports.md.
1. The problem with three processes#
The dual-port runtime is three processes with a fragile dependency chain:
Electron shell → Next.js server (Node, web port) → Python worker (backend port)
- Next
rewrites()fixes the/wsproxy target at build time. A build run without the right profile env points/wsat a dead port, and every client shows "disconnected". Two workarounds exist solely for this:_patch_manifest_portsregex-rewriting.next/routes-manifest.json(openprogram/worker/web.py), and the/api/[...path]route handler re-readingworker.portper request. - The worker spawns and supervises the Next server: ~330 lines in
openprogram/worker/web.pyfor port reclaim, orphannext-serverkilling, BUILD_ID watching, manifest patching, parent-PID watch (web/scripts/with-parent-watch.mjs). - Users need Node at runtime just to render a UI that is already 100% client-side.
2. Why merging the two is cheap#
The frontend has nothing that requires a Node server:
- The app shell is loaded with
next/dynamic+ssr: false(web/app/(shell)/layout.tsx); every real page is"use client". - No
middleware.ts, no server actions, nonext/image, nooutput/basePath/headersconfig to unwind. - The only two route handlers (
app/api/[...path],app/files/[...path]) are proxies to the worker — the origin server under single-port. - The worker's FastAPI app already serves static content (
/docsdocs-site mount,/files/raw) and hasdocs_url=None, so there are no route collisions.
3. Design#
One process, one port. The Python worker serves everything:
Electron shell → Python worker (FastAPI, single port)
├─ /ws native WebSocket (index-0 route)
├─ /api/* native routers
├─ /files/raw
├─ /docs/*
└─ /* Next static export (out/) + SPA fallback
3.1 The frontend is a static export#
web/next.config.mjs:
output: "export"→next buildemits plain HTML/JS/CSS intoweb/out/.- No
rewrites()and noresolveBackend()— there is no proxy target to resolve. - Frontend code talks to its own origin (
/ws,/api/...relative URLs).
Dynamic page segments ((shell)/s/[sessionId], (shell)/skills/[...name],
(shell)/settings/providers/[providerId], plugin/[name]/[...slug]) are
route markers that render null or resolve params client-side from
pathname. Static export rejects them without generateStaticParams, so
those page files do not exist; the SPA fallback (3.2) serves the shell for
those paths and client-side routing handles the rest. A segment that does
real work keeps a generateStaticParams returning one placeholder instead.
app/api/[...path]/route.ts and app/files/[...path]/route.ts do not exist.
3.2 The worker serves the export#
openprogram/webui/frontend.py, mounted last in create_app():
- Static files from
web/out/(immutable cache headers for/_next/static, no-cache for HTML). - SPA fallback: any GET not matching a file or an API route returns the
shell HTML (
out/chat.html— the app redirects/→/chatand resolves everything else frompathname). - Build gate: if
web/out/is missing or older thanweb/sources, runnpm run buildonce at startup. Node is then a build-time dependency only; a packaged release shipsout/pre-built and never invokes Node.
3.3 No process supervision#
Nothing spawns or watches a Node process. openprogram/worker/web.py
(spawn, port reclaim, manifest patch, BUILD_ID watcher),
web/scripts/with-parent-watch.mjs, and the start_web_frontend call in
openprogram/worker/runner.py have no counterpart here.
3.4 Port semantics#
The backend port is the port; web-port knobs are retired:
| dual-port | single-port | |
|---|---|---|
| stable | web 18100 / backend 18109 | 18100 |
| dev | web 18200 / backend 18209 | 18200 |
OPENPROGRAM_WEB_PORTand theweb_portUI pref are accepted as aliases for the backend port during a deprecation window (logging a warning), then removed.worker.portfile: unchanged, still the single source of truth for discovery.- Electron
desktop/main.js: theWEB_PORTconstant (18200 dev / 18100 release) simply is the worker port; the three usage sites (start URL, origin check, navigation guard) need no structural change. scripts/promote_stable.sh:npm run buildemitsout/.
4. Invariants#
- The backend is the sole origin. No proxy layer, no second server, no
port fixed at build time anywhere. The
/wstarget is correct by construction because it is the same origin the page loaded from. - API routes always win over static. The frontend mount registers last; the SPA fallback runs only for paths no router claimed.
- Node is build-time only. Runtime dependencies are Python plus the worker.
5. Trade-offs#
- Dev iteration loses
next devHMR against the merged origin.npm run devkeeps working by pointing at a running worker through a dev-only env (NEXT_PUBLIC_BACKEND_ORIGIN) read by the ws/api client helpers; the production code path stays origin-relative. - A dynamic segment that actually rendered content would 404 its deep link once its page file is gone. The SPA fallback covers this, and each of the four segments is verified individually.
out/can go stale after pulling frontend changes. The startup build gate (mtime check) covers it, andopenprogram restartaftergit pullis the documented workflow.
6. Acceptance criteria#
openprogram(dev profile) starts exactly one listening port;lsofshows nonext-server.- Fresh page loads on
/chat,/s/<id>,/settings/providers/<id>, and/skills/<name>all render;/wsconnects;/api/pick-folderworks. - Killing the worker leaves an already-loaded page showing disconnected; restarting reconnects. No orphan processes on any port.
- A build run with no profile env produces a working instance — the fixed-at-build-time port failure class is gone by construction.
- Full test suite passes; desktop app repackaged and verified.
Roadmap context#
Single port is the first of three steps toward a zero-dependency install:
- Single port (this document): the worker serves the frontend; Node becomes build-time only.
- Shell supervises worker: Electron spawns, watches, and restarts the worker with a real status page, covering first-run bootstrap progress.
- Zero-dependency install via uv: the packaged app ships Electron, the
prebuilt
out/, and the standaloneuvbinary (~15 MB). First launch runsuv python install(python-build-standalone, app-private, never touching the system Python) anduv syncfrom the lockfile; later launches reuse the installed environment. Mirrors (UV_PYTHON_INSTALL_MIRROR, a CN PyPI mirror) are required for first-run reliability in China. No PyInstaller.
Appendix: Implementation Status#
The design is approved. Steps 2 and 3 of the roadmap build on this one and get their own documents.