feat(ui): proxy dev API and WebSocket requests to a configurable CC

This commit is contained in:
lda committed 2026-10-21 00:48:12 +07:00 Verified
1 parent 03c1f14dba
commit b1a515e1aa
4 files changed
+167 -17

No files matched your search

+20
View File
@@ -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:
+2
View File
@@ -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
+97
View File
@@ -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));
}
});
+48 -17
View File
@@ -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,
};
});