From 146eae5220b2bbb0d122c14b639519a3025c4e90 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 19 Nov 2025 02:03:15 +0000 Subject: [PATCH] Add CLI instance proxy and route UI traffic through it --- AGENTS.md | 4 + README.md | 5 + dev-docs/architecture.md | 7 + package-lock.json | 373 ++++++++++++++++++++++--- package.json | 3 +- packages/cli/.npmignore | 5 + packages/cli/src/api-types.ts | 2 + packages/cli/src/index.ts | 1 + packages/cli/src/server/http-server.ts | 153 ++++++++++ packages/cli/src/workspaces/manager.ts | 7 + packages/ui/src/lib/api-client.ts | 2 + packages/ui/src/lib/sdk-manager.ts | 39 ++- packages/ui/src/lib/sse-manager.ts | 36 ++- packages/ui/src/stores/instances.ts | 38 ++- packages/ui/src/types/instance.ts | 1 + 15 files changed, 592 insertions(+), 84 deletions(-) create mode 100644 packages/cli/.npmignore diff --git a/AGENTS.md b/AGENTS.md index d6a09c8e..3017aaea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,3 +14,7 @@ - Enforce single responsibility; split large files when concerns diverge (state, actions, API, events, etc.). - Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state. - When adding platform integrations (SSE, IPC, SDK), isolate them in thin adapters that surface typed events/actions. + +## Tooling Preferences +- Use the `edit` tool for modifying existing files; prefer it over other editing methods. +- Use the `write` tool only when creating new files from scratch. diff --git a/README.md b/README.md index 868c3470..bf397c5c 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,11 @@ The bundled CLI server (`@codenomad/cli`) controls which folders the UI can brow - `--workspace-root ` (default: current working directory) scopes browsing to a safe subtree. The UI can only see folders beneath this root. - `--unrestricted-root` explicitly allows full-machine browsing for the current process. In this mode the UI starts from the host home directory, adds a "parent" option so you can reach `/` on macOS/Linux, and lists drives/UNC paths on Windows. The flag is runtime-only—restart the CLI without it to go back to restricted mode. +- `--ui-dev-server ` proxies UI asset requests to a running Vite dev server while the CLI continues to expose its REST APIs and workspace proxies from the same port. Point this at `http://localhost:3000` when developing the renderer to keep hot reloads without sacrificing the single entry point. Use unrestricted mode only when you trust the host; the CLI will skip directories it cannot read and never persists the opt-in. +### Single Port Proxying + +Every OpenCode instance now tunnels through the CLI port. Each workspace descriptor publishes a stable `proxyPath` (e.g., `/workspaces//instance`), and the CLI exposes `GET/POST/...` + SSE at `http(s)://:${proxyPath}`. That means the UI, Electron shell, and browser clients only need firewall access to the CLI; instance ports stay private on `127.0.0.1`. In development, the `--ui-dev-server` flag still routes UI traffic through the CLI proxy so all instance calls share the same origin. + diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index 6c8dec9f..d342e258 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -104,6 +104,12 @@ CodeNomad is a cross-platform desktop application built with Electron that provi - Event type routing - Reconnection logic +**CLI Proxy Paths:** + +- The CLI server terminates all HTTP/SSE traffic and forwards it to the correct OpenCode instance. +- Each `WorkspaceDescriptor` exposes `proxyPath` (e.g., `/workspaces//instance`), which acts as the base URL for both REST and SSE calls. +- The renderer never touches the random per-instance port directly; it only talks to `window.location.origin + proxyPath` so a single CLI port can front every session. + ## Data Flow ### Instance Creation Flow @@ -144,6 +150,7 @@ instances: Map=18" } @@ -1683,6 +1684,7 @@ "integrity": "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1822,6 +1824,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2050,7 +2053,6 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -2070,7 +2072,6 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -2093,7 +2094,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -2109,8 +2109,7 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/archiver-utils/node_modules/string_decoder": { "version": "1.1.1", @@ -2118,7 +2117,6 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -2309,7 +2307,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -2336,6 +2333,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2355,7 +2361,6 @@ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -2430,6 +2435,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -2492,6 +2498,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2891,7 +2903,6 @@ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -3019,7 +3030,6 @@ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "crc32": "bin/crc32.njs" }, @@ -3033,7 +3043,6 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -3101,11 +3110,19 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3310,6 +3327,7 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -3425,6 +3443,15 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -3493,7 +3520,6 @@ "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -3507,7 +3533,6 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -3523,7 +3548,6 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -3537,7 +3561,6 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -3846,6 +3869,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -4095,6 +4124,29 @@ "pend": "~1.2.0" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -4165,6 +4217,18 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4193,8 +4257,7 @@ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fs-extra": { "version": "8.1.0", @@ -4276,6 +4339,57 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4486,6 +4600,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4532,6 +4673,19 @@ "dev": true, "license": "ISC" }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4949,8 +5103,7 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/isbinaryfile": { "version": "5.0.6", @@ -5010,6 +5163,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -5047,6 +5201,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -5101,6 +5264,27 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5124,7 +5308,6 @@ "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readable-stream": "^2.0.5" }, @@ -5138,7 +5321,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -5154,8 +5336,7 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lazystream/node_modules/string_decoder": { "version": "1.1.1", @@ -5163,7 +5344,6 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -5211,40 +5391,35 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.flatten": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.union": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lowercase-keys": { "version": "2.0.0", @@ -5631,7 +5806,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -5673,6 +5847,44 @@ "license": "MIT", "optional": true }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -6018,6 +6230,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6166,8 +6379,7 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/process-warning": { "version": "3.0.0", @@ -6341,7 +6553,6 @@ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -6357,7 +6568,6 @@ "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "minimatch": "^5.1.0" } @@ -6513,6 +6723,65 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", @@ -6706,6 +6975,7 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz", "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -6842,6 +7112,7 @@ "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.10.tgz", "integrity": "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", @@ -6988,7 +7259,6 @@ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -7242,7 +7512,6 @@ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -7517,6 +7786,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7740,6 +8010,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -7895,6 +8166,15 @@ } } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8036,7 +8316,6 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -8052,7 +8331,6 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", @@ -8100,6 +8378,9 @@ "undici": "^6.19.8", "zod": "^3.23.8" }, + "bin": { + "codenomad-cli": "dist/bin.js" + }, "devDependencies": { "cross-env": "^7.0.3", "ts-node": "^10.9.2", diff --git a/package.json b/package.json index 21f1867d..efe62d4f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @codenomad/electron-app" }, "dependencies": { - "7zip-bin": "^5.2.0" + "7zip-bin": "^5.2.0", + "google-auth-library": "^10.5.0" } } diff --git a/packages/cli/.npmignore b/packages/cli/.npmignore new file mode 100644 index 00000000..d50860b4 --- /dev/null +++ b/packages/cli/.npmignore @@ -0,0 +1,5 @@ +node_modules +scripts/ +src/ +tsconfig.json +*.tsbuildinfo diff --git a/packages/cli/src/api-types.ts b/packages/cli/src/api-types.ts index 67912fac..bf74e7dc 100644 --- a/packages/cli/src/api-types.ts +++ b/packages/cli/src/api-types.ts @@ -23,6 +23,8 @@ export interface WorkspaceDescriptor { /** PID/port are populated when the workspace is running. */ pid?: number port?: number + /** Canonical proxy path the CLI exposes for this instance. */ + proxyPath: string /** Identifier of the binary resolved from config. */ binaryId: string binaryLabel: string diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 152c5944..3e56c6ee 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -136,6 +136,7 @@ async function main() { instanceStore, uiStaticDir: options.uiStaticDir, uiDevServerUrl: options.uiDevServer, + logger, }) diff --git a/packages/cli/src/server/http-server.ts b/packages/cli/src/server/http-server.ts index 8eb9dd91..044d683b 100644 --- a/packages/cli/src/server/http-server.ts +++ b/packages/cli/src/server/http-server.ts @@ -3,8 +3,12 @@ import cors from "@fastify/cors" import fastifyStatic from "@fastify/static" import fs from "fs" import path from "path" +import { Readable } from "node:stream" +import type { ReadableStream as NodeReadableStream } from "node:stream/web" import { fetch } from "undici" +import type { Logger } from "../logger" import { WorkspaceManager } from "../workspaces/manager" + import { ConfigStore } from "../config/store" import { BinaryRegistry } from "../config/binaries" import { FileSystemBrowser } from "../filesystem/browser" @@ -30,11 +34,13 @@ interface HttpServerDeps { instanceStore: InstanceStore uiStaticDir: string uiDevServerUrl?: string + logger: Logger } export function createHttpServer(deps: HttpServerDeps) { const app = Fastify({ logger: false }) + const proxyLogger = deps.logger.child({ component: "proxy" }) const sseClients = new Set<() => void>() const registerSseClient = (cleanup: () => void) => { @@ -59,6 +65,7 @@ export function createHttpServer(deps: HttpServerDeps) { registerMetaRoutes(app, { serverMeta: deps.serverMeta }) registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient }) registerStorageRoutes(app, { instanceStore: deps.instanceStore }) + registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) if (deps.uiDevServerUrl) { setupDevProxy(app, deps.uiDevServerUrl) @@ -76,6 +83,152 @@ export function createHttpServer(deps: HttpServerDeps) { } } +interface InstanceProxyDeps { + workspaceManager: WorkspaceManager + logger: Logger +} + +function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) { + app.register(async (instance) => { + instance.removeAllContentTypeParsers() + instance.addContentTypeParser("*", { parseAs: "buffer" }, (req, body, done) => done(null, body)) + + const proxyBaseHandler = async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { + await proxyWorkspaceRequest({ + request, + reply, + workspaceManager: deps.workspaceManager, + pathSuffix: "", + logger: deps.logger, + }) + } + + const proxyWildcardHandler = async ( + request: FastifyRequest<{ Params: { id: string; "*": string } }>, + reply: FastifyReply, + ) => { + await proxyWorkspaceRequest({ + request, + reply, + workspaceManager: deps.workspaceManager, + pathSuffix: request.params["*"] ?? "", + logger: deps.logger, + }) + } + + instance.all("/workspaces/:id/instance", proxyBaseHandler) + instance.all("/workspaces/:id/instance/*", proxyWildcardHandler) + }) +} + +const INSTANCE_PROXY_HOST = "127.0.0.1" +const METHODS_WITHOUT_BODY = new Set(["GET", "HEAD", "OPTIONS"]) + +async function proxyWorkspaceRequest(args: { + request: FastifyRequest + reply: FastifyReply + workspaceManager: WorkspaceManager + logger: Logger + pathSuffix?: string +}) { + const { request, reply, workspaceManager, logger } = args + const workspaceId = (request.params as { id: string }).id + const workspace = workspaceManager.get(workspaceId) + + if (!workspace) { + reply.code(404).send({ error: "Workspace not found" }) + return + } + + const port = workspaceManager.getInstancePort(workspaceId) + if (!port) { + reply.code(502).send({ error: "Workspace instance is not ready" }) + return + } + + const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix) + const queryIndex = (request.raw.url ?? "").indexOf("?") + const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : "" + const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}` + + try { + const abortController = new AbortController() + const bodyPayload = METHODS_WITHOUT_BODY.has(request.method.toUpperCase()) + ? undefined + : (request.body as Buffer | undefined) + const headers = buildProxyHeaders(request.headers) + if (bodyPayload && bodyPayload.byteLength > 0) { + headers["content-length"] = String(bodyPayload.byteLength) + } else { + delete headers["content-length"] + } + const response = await fetch(targetUrl, { + method: request.method, + headers, + body: bodyPayload, + signal: abortController.signal, + }) + + const headersToForward: Record = {} + response.headers.forEach((value, key) => { + if (key.toLowerCase() === "content-length") { + return + } + headersToForward[key] = value + }) + + const contentType = (response.headers.get("content-type") ?? "").toLowerCase() + const isEventStream = contentType.includes("text/event-stream") + + if (isEventStream && response.body) { + reply.hijack() + Object.entries(headersToForward).forEach(([key, value]) => reply.raw.setHeader(key, value)) + reply.raw.setHeader("Cache-Control", "no-cache") + reply.raw.setHeader("Connection", "keep-alive") + reply.raw.setHeader("Content-Type", "text/event-stream") + reply.raw.writeHead(response.status) + const stream = Readable.fromWeb(response.body as NodeReadableStream) + const cleanup = () => { + stream.destroy() + abortController.abort() + } + request.raw.on("close", cleanup) + request.raw.on("error", cleanup) + stream.on("error", cleanup) + stream.pipe(reply.raw) + return + } + + Object.entries(headersToForward).forEach(([key, value]) => reply.header(key, value)) + + reply.code(response.status) + + if (request.method === "HEAD") { + reply.send() + abortController.abort() + return + } + + const bodyBuffer = Buffer.from(await response.arrayBuffer()) + reply.header("content-length", String(bodyBuffer.byteLength)) + reply.send(bodyBuffer) + abortController.abort() + } catch (error) { + logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request") + if (!reply.sent) { + reply.code(502).send({ error: "Workspace instance proxy failed" }) + } + } +} + +function normalizeInstanceSuffix(pathSuffix: string | undefined) { + if (!pathSuffix || pathSuffix === "/") { + return "/" + } + const trimmed = pathSuffix.replace(/^\/+/, "") + return trimmed.length === 0 ? "/" : `/${trimmed}` +} + function setupStaticUi(app: FastifyInstance, uiDir: string) { if (!uiDir) { app.log.warn("UI static directory not provided; API endpoints only") diff --git a/packages/cli/src/workspaces/manager.ts b/packages/cli/src/workspaces/manager.ts index 6b9b804f..80f98262 100644 --- a/packages/cli/src/workspaces/manager.ts +++ b/packages/cli/src/workspaces/manager.ts @@ -33,6 +33,10 @@ export class WorkspaceManager { return this.workspaces.get(id) } + getInstancePort(id: string): number | undefined { + return this.workspaces.get(id)?.port + } + listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] { const workspace = this.requireWorkspace(workspaceId) const browser = new FileSystemBrowser({ rootDir: workspace.path }) @@ -57,11 +61,14 @@ export class WorkspaceManager { this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: binary.path }, "Creating workspace") + const proxyPath = `/workspaces/${id}/instance` + const descriptor: WorkspaceRecord = { id, path: workspacePath, name, status: "starting", + proxyPath, binaryId: binary.id, binaryLabel: binary.label, binaryVersion: binary.version, diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 9fd4cce4..62fdee54 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -25,6 +25,8 @@ const DEFAULT_EVENTS_PATH = typeof window !== "undefined" ? window.__CODENOMAD_E const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH) +export const CODENOMAD_API_BASE = API_BASE + function buildEventsUrl(base: string | undefined, path: string): string { if (path.startsWith("http://") || path.startsWith("https://")) { return path diff --git a/packages/ui/src/lib/sdk-manager.ts b/packages/ui/src/lib/sdk-manager.ts index 0f681830..c394b49c 100644 --- a/packages/ui/src/lib/sdk-manager.ts +++ b/packages/ui/src/lib/sdk-manager.ts @@ -1,27 +1,27 @@ import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/client" +import { CODENOMAD_API_BASE } from "./api-client" class SDKManager { - private clients = new Map() + private clients = new Map() - createClient(port: number): OpencodeClient { - if (this.clients.has(port)) { - return this.clients.get(port)! + createClient(instanceId: string, proxyPath: string): OpencodeClient { + if (this.clients.has(instanceId)) { + return this.clients.get(instanceId)! } - const client = createOpencodeClient({ - baseUrl: `http://localhost:${port}`, - }) + const baseUrl = buildInstanceBaseUrl(proxyPath) + const client = createOpencodeClient({ baseUrl }) - this.clients.set(port, client) + this.clients.set(instanceId, client) return client } - getClient(port: number): OpencodeClient | null { - return this.clients.get(port) || null + getClient(instanceId: string): OpencodeClient | null { + return this.clients.get(instanceId) ?? null } - destroyClient(port: number): void { - this.clients.delete(port) + destroyClient(instanceId: string): void { + this.clients.delete(instanceId) } destroyAll(): void { @@ -29,4 +29,19 @@ class SDKManager { } } +function buildInstanceBaseUrl(proxyPath: string): string { + const normalized = normalizeProxyPath(proxyPath) + const base = stripTrailingSlashes(CODENOMAD_API_BASE) + return `${base}${normalized}/` +} + +function normalizeProxyPath(proxyPath: string): string { + const withLeading = proxyPath.startsWith("/") ? proxyPath : `/${proxyPath}` + return withLeading.replace(/\/+/g, "/").replace(/\/+$/, "") +} + +function stripTrailingSlashes(input: string): string { + return input.replace(/\/+$/, "") +} + export const sdkManager = new SDKManager() diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index 103037d0..90e86df6 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -1,9 +1,9 @@ import { createSignal } from "solid-js" -import { - MessageUpdateEvent, - MessageRemovedEvent, - MessagePartUpdatedEvent, - MessagePartRemovedEvent +import { + MessageUpdateEvent, + MessageRemovedEvent, + MessagePartUpdatedEvent, + MessagePartRemovedEvent, } from "../types/message" import type { EventLspUpdated, @@ -14,10 +14,11 @@ import type { EventSessionIdle, EventSessionUpdated, } from "@opencode-ai/sdk" +import { CODENOMAD_API_BASE } from "./api-client" interface SSEConnection { instanceId: string - port: number + proxyPath: string eventSource: EventSource status: "connecting" | "connected" | "disconnected" | "error" reconnectAttempts: number @@ -57,19 +58,19 @@ class SSEManager { private connections = new Map() private static readonly MAX_RECONNECT_ATTEMPTS = 3 - connect(instanceId: string, port: number, reconnectAttempts = 0): void { + connect(instanceId: string, proxyPath: string, reconnectAttempts = 0): void { const existing = this.connections.get(instanceId) if (existing) { this.clearReconnectTimer(existing) existing.eventSource.close() } - const url = `http://localhost:${port}/event` + const url = buildInstanceEventsUrl(proxyPath) const eventSource = new EventSource(url) const connection: SSEConnection = { instanceId, - port, + proxyPath, eventSource, status: "connecting", reconnectAttempts, @@ -180,7 +181,7 @@ class SSEManager { connection.reconnectTimer = setTimeout(() => { connection.reconnectTimer = undefined - this.connect(instanceId, connection.port, nextAttempt) + this.connect(instanceId, connection.proxyPath, nextAttempt) }, delay) } @@ -234,4 +235,19 @@ class SSEManager { } } +function buildInstanceEventsUrl(proxyPath: string): string { + const normalized = normalizeProxyPath(proxyPath) + const base = stripTrailingSlashes(CODENOMAD_API_BASE) + return `${base}${normalized}/event` +} + +function normalizeProxyPath(proxyPath: string): string { + const withLeading = proxyPath.startsWith("/") ? proxyPath : `/${proxyPath}` + return withLeading.replace(/\/+/g, "/").replace(/\/+$/, "") +} + +function stripTrailingSlashes(input: string): string { + return input.replace(/\/+$/, "") +} + export const sseManager = new SSEManager() diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 5f6f1640..ccd80776 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -45,6 +45,7 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc folder: descriptor.path, port: descriptor.port ?? existing?.port ?? 0, pid: descriptor.pid ?? existing?.pid ?? 0, + proxyPath: descriptor.proxyPath, status: descriptor.status, error: descriptor.error, client: existing?.client ?? null, @@ -63,32 +64,39 @@ function upsertWorkspace(descriptor: WorkspaceDescriptor) { setHasInstances(true) } - if (descriptor.status === "ready" && descriptor.port) { - attachClient(descriptor.id, descriptor.port) + if (descriptor.status === "ready") { + attachClient(descriptor) } } -function attachClient(instanceId: string, port: number) { - const instance = instances().get(instanceId) +function attachClient(descriptor: WorkspaceDescriptor) { + const instance = instances().get(descriptor.id) if (!instance) return - if (instance.port === port && instance.client) { + const nextPort = descriptor.port ?? instance.port + const nextProxyPath = descriptor.proxyPath + + if (instance.client && instance.proxyPath === nextProxyPath) { + if (nextPort && instance.port !== nextPort) { + updateInstance(descriptor.id, { port: nextPort }) + } return } - if (instance.port && instance.client) { - sdkManager.destroyClient(instance.port) - sseManager.disconnect(instanceId) + if (instance.client) { + sdkManager.destroyClient(descriptor.id) + sseManager.disconnect(descriptor.id) } - const client = sdkManager.createClient(port) - updateInstance(instanceId, { + const client = sdkManager.createClient(descriptor.id, nextProxyPath) + updateInstance(descriptor.id, { client, - port, + port: nextPort ?? 0, + proxyPath: nextProxyPath, status: "ready", }) - sseManager.connect(instanceId, port) - void hydrateInstanceData(instanceId).catch((error) => { + sseManager.connect(descriptor.id, nextProxyPath) + void hydrateInstanceData(descriptor.id).catch((error) => { console.error("Failed to hydrate instance data", error) }) } @@ -97,8 +105,8 @@ function releaseInstanceResources(instanceId: string) { const instance = instances().get(instanceId) if (!instance) return - if (instance.port) { - sdkManager.destroyClient(instance.port) + if (instance.client) { + sdkManager.destroyClient(instanceId) } sseManager.disconnect(instanceId) } diff --git a/packages/ui/src/types/instance.ts b/packages/ui/src/types/instance.ts index 6fa5ddf5..cc4ea1ce 100644 --- a/packages/ui/src/types/instance.ts +++ b/packages/ui/src/types/instance.ts @@ -33,6 +33,7 @@ export interface Instance { folder: string port: number pid: number + proxyPath: string status: "starting" | "ready" | "error" | "stopped" error?: string client: OpencodeClient | null