From b1a515e1aa420386d444d04634d29f41b52b2abc Mon Sep 17 00:00:00 2001 From: lda Date: Wed, 21 Oct 2026 00:48:12 +0700 Subject: [PATCH] feat(ui): proxy dev API and WebSocket requests to a configurable CC --- README.md | 20 ++++++++ ui/.env.example | 2 + ui/tests/dev-proxy.test.mjs | 97 +++++++++++++++++++++++++++++++++++++ ui/vite.config.ts | 65 ++++++++++++++++++------- 4 files changed, 167 insertions(+), 17 deletions(-) create mode 100644 ui/.env.example create mode 100644 ui/tests/dev-proxy.test.mjs diff --git a/README.md b/README.md index 38ded81..ce8a75e 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,26 @@ pnpm build Then start control-plane and open `/ui/` on the same host/port. +For UI development with hot reload, run `pnpm --dir ui dev` and open +`http://127.0.0.1:5173/ui/` (Vite chooses another port if occupied). API requests +and terminal/event WebSockets proxy to `http://127.0.0.1:8080` by default. +Override the backend with `WAKEY_DEV_API_TARGET=http://127.0.0.1:18080 pnpm --dir ui dev`, +or set it in `ui/.env` using `ui/.env.example`. Restart Vite after changing it. +This setting is dev-only; built assets still use the serving CC's origin. + +For the remote CC, first open an SSH tunnel: + +```sh +ssh -N -L 127.0.0.1:18080:127.0.0.1:23501 cloud +``` + +Use `http://127.0.0.1:18080` as the dev target. This connects directly to CC +through SSH, avoiding Cloudflare Access browser redirects/cookie handling in the +proxy. The dev server binds to loopback; do not expose this authenticated tunnel +through a public Vite server. Changes made in the dev UI affect the selected +backend, including production if that is what you tunnel to. HTTPS targets retain +certificate verification; the proxy does not add Cloudflare credentials. + ## VPS Deploy (Manual Updates) Suggested layout on VPS: diff --git a/ui/.env.example b/ui/.env.example new file mode 100644 index 0000000..f29ebf4 --- /dev/null +++ b/ui/.env.example @@ -0,0 +1,2 @@ +# Vite dev server only. Use an origin, without /ui or /api suffixes. +WAKEY_DEV_API_TARGET=http://127.0.0.1:8080 diff --git a/ui/tests/dev-proxy.test.mjs b/ui/tests/dev-proxy.test.mjs new file mode 100644 index 0000000..b9c0450 --- /dev/null +++ b/ui/tests/dev-proxy.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { createServer as createHttpServer, request } from "node:http"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { createServer } from "vite"; + +test("dev proxy preserves API requests and WebSocket upgrades under /ui base", async () => { + const sockets = new Set(); + const backend = createHttpServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + res.setHeader("Content-Type", "application/json"); + res.end( + JSON.stringify({ + method: req.method, + url: req.url, + body: Buffer.concat(chunks).toString(), + host: req.headers.host, + }), + ); + }); + backend.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + backend.on("upgrade", (req, socket) => { + socket.write( + "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", + ); + }); + backend.listen(0, "127.0.0.1"); + await once(backend, "listening"); + const target = `http://127.0.0.1:${backend.address().port}`; + const previous = process.env.WAKEY_DEV_API_TARGET; + process.env.WAKEY_DEV_API_TARGET = target; + let vite; + try { + vite = await createServer({ + root: fileURLToPath(new URL("../", import.meta.url)), + server: { port: 0 }, + }); + await vite.listen(); + const origin = `http://127.0.0.1:${vite.httpServer.address().port}`; + const response = await fetch( + `${origin}/api/v1/control/fleet/devices/refresh?test=1`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: '{"agent_ids":[]}', + }, + ); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + method: "POST", + url: "/api/v1/control/fleet/devices/refresh?test=1", + body: '{"agent_ids":[]}', + host: new URL(target).host, + }); + assert.equal((await fetch(`${origin}/healthz`)).status, 200); + const page = await fetch(`${origin}/ui/`); + assert.equal(page.status, 200); + assert.match(await page.text(), /@vite\/client/); + await new Promise((resolve, reject) => { + const req = request( + `${origin}/api/v1/control/terminals/events/ws?test=1`, + { + headers: { + Connection: "Upgrade", + Upgrade: "websocket", + "Sec-WebSocket-Version": "13", + "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", + }, + }, + ); + req.on("upgrade", (res, socket) => { + socket.destroy(); + resolve(); + }); + req.on("response", (res) => { + res.resume(); + reject(new Error(`Expected WS upgrade, got ${res.statusCode}`)); + }); + req.on("error", reject); + req.setTimeout(5000, () => + req.destroy(new Error("WS upgrade timed out")), + ); + req.end(); + }); + } finally { + if (previous === undefined) delete process.env.WAKEY_DEV_API_TARGET; + else process.env.WAKEY_DEV_API_TARGET = previous; + for (const socket of sockets) socket.destroy(); + if (vite) await vite.close(); + await new Promise((resolve) => backend.close(resolve)); + } +}); diff --git a/ui/vite.config.ts b/ui/vite.config.ts index e763d3d..05bbe23 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -21,22 +21,53 @@ const ReactCompilerConfig = { runtimeModule: "react-compiler-runtime", // Redirects the missing specifier }; -export default defineConfig({ - plugins: [ - react({ - babel: { - plugins: [["babel-plugin-react-compiler", ReactCompilerConfig]], +export default defineConfig(({ command, mode, isPreview }) => { + const env = loadEnv(mode, __dirname, "WAKEY_DEV_"); + const target = env.WAKEY_DEV_API_TARGET || "http://127.0.0.1:8080"; + const dev = command === "serve" && !isPreview; + if (dev) { + const url = new URL(target); + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash + ) { + throw new Error( + "WAKEY_DEV_API_TARGET must be an HTTP(S) origin without credentials or a path", + ); + } + } + return { + plugins: [ + react({ + babel: { + plugins: [["babel-plugin-react-compiler", ReactCompilerConfig]], + }, + }), + tailwindcss(), + ], + base: "/ui/", + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), }, - }), - tailwindcss(), - ], - base: "/ui/", - resolve: { - alias: { - "@": path.resolve(__dirname, "src"), }, - }, - build: { - sourcemap: debugEnabled(), - }, + build: { + sourcemap: debugEnabled(), + }, + // Keep browser requests same-origin, including terminal/event WebSockets. + // WAKEY_DEV_* is read only by this config, never exposed as VITE_* client data. + server: dev + ? { + host: "127.0.0.1", + proxy: { + "^/api/": { target, changeOrigin: true, ws: true }, + "^/healthz$": { target, changeOrigin: true }, + }, + } + : undefined, + }; });