- Add Vite resolve.alias for react/react-dom to root node_modules - Removes duplicate React instances from pnpm transitive deps - Create E2E smoke test verifying no React hook errors - Fix: pnpm resolves @dnd-kit deps to react@19.2.8, conflicting with app react@19.2.3 causing 'Invalid hook call' at runtime Fixes: dark screen / Invalid hook call on /app
343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
import { timingSafeEqual } from "node:crypto";
|
|
import type { ClientRequest, IncomingMessage } from "node:http";
|
|
import http from "node:http";
|
|
import https from "node:https";
|
|
import net from "node:net";
|
|
import path from "node:path";
|
|
import tls from "node:tls";
|
|
import { lingui } from "@lingui/vite-plugin";
|
|
import type { DesktopStackProbeResponse } from "@rakazo/contracts";
|
|
import {
|
|
safeScreenProxyResponseHeaders,
|
|
stripSensitiveHandshakeHeaders,
|
|
} from "@rakazo/core/node/screen-proxy-response";
|
|
import babel from "@rolldown/plugin-babel";
|
|
import tailwindcss from "@tailwindcss/vite";
|
|
import react from "@vitejs/plugin-react";
|
|
import type { PreviewServer, ViteDevServer } from "vite";
|
|
import { defineConfig, loadEnv } from "vite";
|
|
import { resolveScreenProxySecret } from "../../packages/core/src/secrets-guard.ts";
|
|
import {
|
|
resolveNovncTarget,
|
|
safeProxyHeaders,
|
|
watchScreenAuthorization,
|
|
} from "./src/screen-proxy.js";
|
|
|
|
const webPort = Number(process.env.WEB_PORT ?? 5173);
|
|
const DESKTOP_STACK_PROBE_PATH = "/.well-known/rakazo-desktop-stack";
|
|
const DESKTOP_STACK_TOKEN_HEADER = "x-rakazo-desktop-stack-token";
|
|
|
|
function equalStackToken(expected: string, supplied: string | string[] | undefined) {
|
|
if (expected === "" || typeof supplied !== "string") return false;
|
|
const expectedBytes = Buffer.from(expected);
|
|
const suppliedBytes = Buffer.from(supplied);
|
|
return (
|
|
expectedBytes.byteLength === suppliedBytes.byteLength &&
|
|
timingSafeEqual(expectedBytes, suppliedBytes)
|
|
);
|
|
}
|
|
|
|
function attachDesktopStackProbe(
|
|
server: ViteDevServer | PreviewServer,
|
|
token: string,
|
|
imageTag: string,
|
|
) {
|
|
server.middlewares.use((req, res, next) => {
|
|
if (req.url?.split("?", 1)[0] !== DESKTOP_STACK_PROBE_PATH) {
|
|
next();
|
|
return;
|
|
}
|
|
if (!equalStackToken(token, req.headers[DESKTOP_STACK_TOKEN_HEADER])) {
|
|
res.statusCode = 404;
|
|
res.end("Not found");
|
|
return;
|
|
}
|
|
const body: DesktopStackProbeResponse = { ok: true, imageTag };
|
|
res.statusCode = 200;
|
|
res.setHeader("cache-control", "no-store");
|
|
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
res.end(JSON.stringify(body));
|
|
});
|
|
}
|
|
|
|
function attachNovncProxy(server: ViteDevServer | PreviewServer, secret: string, api: string) {
|
|
server.middlewares.use(async (req, res, next) => {
|
|
if (!req.url?.startsWith("/novnc/")) {
|
|
next();
|
|
return;
|
|
}
|
|
const target = await resolveNovncTarget(req.url, secret, api);
|
|
if (res.destroyed) return;
|
|
if (!target) {
|
|
res.statusCode = 403;
|
|
res.end("Invalid or expired screen capability");
|
|
return;
|
|
}
|
|
const headers = {
|
|
...safeProxyHeaders(req.headers),
|
|
host: `${target.hostname}:${target.port}`,
|
|
};
|
|
const transport = target.protocol === "https:" ? https : http;
|
|
let upstream: ClientRequest | undefined;
|
|
let retries = 0;
|
|
let retryPending = false;
|
|
let stopChecking: () => void = () => undefined;
|
|
const retryable = req.method === "GET";
|
|
const scheduleRetry = (incoming?: IncomingMessage) => {
|
|
if (!retryable || retryPending || retries >= 3 || res.destroyed) return false;
|
|
retryPending = true;
|
|
retries += 1;
|
|
const delayMs = 50 * retries;
|
|
const retry = () => {
|
|
setTimeout(() => {
|
|
retryPending = false;
|
|
if (!res.destroyed) requestUpstream();
|
|
}, delayMs);
|
|
};
|
|
if (incoming && !incoming.destroyed) {
|
|
incoming.once("close", retry);
|
|
incoming.destroy();
|
|
} else {
|
|
retry();
|
|
}
|
|
return true;
|
|
};
|
|
function requestUpstream() {
|
|
if (res.destroyed) return;
|
|
upstream = transport.request(
|
|
{
|
|
hostname: target.hostname,
|
|
port: target.port,
|
|
path: target.path,
|
|
method: req.method,
|
|
headers,
|
|
...(target.protocol === "https:" ? { servername: target.hostname } : {}),
|
|
},
|
|
(incoming) => {
|
|
if ((incoming.statusCode ?? 502) >= 500 && scheduleRetry(incoming)) {
|
|
return;
|
|
}
|
|
res.writeHead(
|
|
incoming.statusCode ?? 502,
|
|
safeScreenProxyResponseHeaders(incoming.headers),
|
|
);
|
|
incoming.pipe(res);
|
|
},
|
|
);
|
|
upstream.on("error", () => {
|
|
if (retryable && !res.headersSent && !res.destroyed && (retryPending || scheduleRetry())) {
|
|
return;
|
|
}
|
|
stopChecking();
|
|
if (res.headersSent) {
|
|
res.destroy();
|
|
return;
|
|
}
|
|
res.statusCode = 502;
|
|
res.end("Screen unavailable");
|
|
});
|
|
if (req.method === "GET" || req.readableEnded) upstream.end();
|
|
else req.pipe(upstream);
|
|
}
|
|
stopChecking = watchScreenAuthorization(
|
|
async () => Boolean(await resolveNovncTarget(req.url, secret, api)),
|
|
() => {
|
|
upstream?.destroy();
|
|
res.destroy();
|
|
},
|
|
);
|
|
res.once("close", () => {
|
|
stopChecking();
|
|
upstream?.destroy();
|
|
});
|
|
requestUpstream();
|
|
});
|
|
|
|
server.httpServer?.on("upgrade", async (req, socket, head) => {
|
|
if (!req.url?.startsWith("/novnc/")) return;
|
|
const target = await resolveNovncTarget(req.url, secret, api);
|
|
if (socket.destroyed) return;
|
|
if (!target) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
const upstream =
|
|
target.protocol === "https:"
|
|
? tls.connect({ port: target.port, host: target.hostname, servername: target.hostname })
|
|
: net.connect(target.port, target.hostname);
|
|
const stopChecking = watchScreenAuthorization(
|
|
async () => Boolean(await resolveNovncTarget(req.url, secret, api)),
|
|
() => {
|
|
socket.destroy();
|
|
upstream.destroy();
|
|
},
|
|
);
|
|
socket.once("close", () => {
|
|
stopChecking();
|
|
upstream.destroy();
|
|
});
|
|
upstream.once("close", () => {
|
|
stopChecking();
|
|
socket.destroy();
|
|
});
|
|
upstream.once(target.protocol === "https:" ? "secureConnect" : "connect", () => {
|
|
const headerLines = [
|
|
`${req.method ?? "GET"} ${target.path} HTTP/1.1`,
|
|
`Host: ${target.hostname}:${target.port}`,
|
|
];
|
|
for (const [key, value] of Object.entries(safeProxyHeaders(req.headers))) {
|
|
headerLines.push(`${key}: ${Array.isArray(value) ? value.join(",") : value}`);
|
|
}
|
|
upstream.write(`${headerLines.join("\r\n")}\r\n\r\n`);
|
|
if (head.length) upstream.write(head);
|
|
socket.pipe(upstream);
|
|
const responseChunks: Buffer[] = [];
|
|
let responseSize = 0;
|
|
let responseTail = Buffer.alloc(0);
|
|
const forwardHandshake = (chunk: Buffer) => {
|
|
responseChunks.push(chunk);
|
|
responseSize += chunk.length;
|
|
if (responseSize > 64 * 1024) {
|
|
socket.destroy();
|
|
upstream.destroy();
|
|
return;
|
|
}
|
|
const boundarySearch = Buffer.concat([responseTail, chunk]);
|
|
if (boundarySearch.indexOf("\r\n\r\n") < 0) {
|
|
responseTail = Buffer.from(boundarySearch.subarray(-3));
|
|
return;
|
|
}
|
|
const responseHead = Buffer.concat(responseChunks, responseSize);
|
|
const safe = stripSensitiveHandshakeHeaders(responseHead);
|
|
if (!safe) {
|
|
socket.destroy();
|
|
upstream.destroy();
|
|
return;
|
|
}
|
|
upstream.off("data", forwardHandshake);
|
|
socket.write(safe);
|
|
upstream.pipe(socket);
|
|
};
|
|
upstream.on("data", forwardHandshake);
|
|
});
|
|
upstream.on("error", () => socket.destroy());
|
|
socket.on("error", () => upstream.destroy());
|
|
});
|
|
}
|
|
|
|
export default defineConfig(({ mode }) => {
|
|
const rootCwd = path.resolve(import.meta.dirname, "../..");
|
|
const reactPath = path.resolve(rootCwd, "node_modules/react");
|
|
const reactDomPath = path.resolve(rootCwd, "node_modules/react-dom");
|
|
const reactIndex = path.resolve(reactPath, "index.js");
|
|
const reactDomIndex = path.resolve(reactDomPath, "index.js");
|
|
const rootEnv = loadEnv(mode, path.resolve(import.meta.dirname, "../.."), "");
|
|
const api = process.env.API_PROXY_TARGET ?? rootEnv.API_PROXY_TARGET ?? "http://127.0.0.1:3100";
|
|
const previewHost = process.env.RAKAZO_HOST ?? rootEnv.RAKAZO_HOST ?? "localhost";
|
|
const screenProxySecret = () =>
|
|
resolveScreenProxySecret({
|
|
...process.env,
|
|
SCREEN_PROXY_SECRET: process.env.SCREEN_PROXY_SECRET ?? rootEnv.SCREEN_PROXY_SECRET,
|
|
SANDBOX_SUPERVISOR_TOKEN:
|
|
process.env.SANDBOX_SUPERVISOR_TOKEN ?? rootEnv.SANDBOX_SUPERVISOR_TOKEN,
|
|
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET ?? rootEnv.BETTER_AUTH_SECRET,
|
|
});
|
|
const performanceAssetDelayMs = Number(process.env.RAKAZO_PERFORMANCE_ASSET_DELAY_MS ?? 0);
|
|
const desktopStackToken =
|
|
process.env.RAKAZO_DESKTOP_STACK_TOKEN ?? rootEnv.RAKAZO_DESKTOP_STACK_TOKEN ?? "";
|
|
const imageTag = process.env.RAKAZO_IMAGE_TAG ?? rootEnv.RAKAZO_IMAGE_TAG ?? "edge";
|
|
return {
|
|
plugins: [
|
|
react(),
|
|
babel({ plugins: ["@lingui/babel-plugin-lingui-macro"] }),
|
|
lingui(),
|
|
tailwindcss(),
|
|
{
|
|
name: "rakazo-desktop-stack-probe",
|
|
configureServer: (server) => attachDesktopStackProbe(server, desktopStackToken, imageTag),
|
|
configurePreviewServer: (server) =>
|
|
attachDesktopStackProbe(server, desktopStackToken, imageTag),
|
|
},
|
|
{
|
|
name: "rakazo-performance-asset-delay",
|
|
configurePreviewServer(server) {
|
|
if (!Number.isFinite(performanceAssetDelayMs) || performanceAssetDelayMs <= 0) return;
|
|
server.middlewares.use((req, _res, next) => {
|
|
const pathname = req.url?.split("?", 1)[0] ?? "/";
|
|
if (["/api", "/rpc", "/novnc"].some((prefix) => pathname.startsWith(prefix))) {
|
|
next();
|
|
return;
|
|
}
|
|
setTimeout(next, performanceAssetDelayMs);
|
|
});
|
|
},
|
|
},
|
|
{
|
|
name: "rakazo-novnc-proxy",
|
|
configureServer: (server) => attachNovncProxy(server, screenProxySecret(), api),
|
|
configurePreviewServer: (server) => attachNovncProxy(server, screenProxySecret(), api),
|
|
},
|
|
{
|
|
// Replace Vite's built-in CORS with origin-echoing CORS for credentials support
|
|
name: "rakazo-cors",
|
|
configureServer: (server) => {
|
|
// Vite's cors middleware runs before our middleware.
|
|
// We handle CORS ourselves and let Vite's cors be a no-op.
|
|
// Since cors: false disables Vite's CORS, we handle everything here.
|
|
server.middlewares.use((req, res, next) => {
|
|
const origin = req.headers.origin;
|
|
if (origin && typeof origin === "string") {
|
|
res.setHeader("access-control-allow-origin", origin);
|
|
res.setHeader("access-control-allow-credentials", "true");
|
|
res.setHeader("access-control-allow-methods", "GET, HEAD, PUT, PATCH, POST, DELETE, OPTIONS");
|
|
res.setHeader("access-control-allow-headers", "content-type, x-rakazo-space-id, authorization");
|
|
res.setHeader("access-control-max-age", "86400");
|
|
if (req.method === "OPTIONS") {
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
}
|
|
next();
|
|
});
|
|
},
|
|
},
|
|
],
|
|
resolve: {
|
|
alias: {
|
|
"react": reactPath,
|
|
"react-dom": reactDomPath,
|
|
"react/jsx-runtime": reactPath,
|
|
"react/jsx-dev-runtime": reactPath,
|
|
},
|
|
},
|
|
server: {
|
|
host: "0.0.0.0",
|
|
port: webPort,
|
|
strictPort: true,
|
|
allowedHosts: true,
|
|
// Disable Vite built-in CORS — our middleware handles it
|
|
cors: false,
|
|
proxy: {
|
|
"/rpc": {
|
|
target: api,
|
|
changeOrigin: false,
|
|
},
|
|
"/api": {
|
|
target: api,
|
|
changeOrigin: false,
|
|
},
|
|
},
|
|
},
|
|
preview: {
|
|
host: "0.0.0.0",
|
|
port: Number(process.env.WEB_PORT ?? 5173),
|
|
allowedHosts: [previewHost],
|
|
proxy: {
|
|
"/api": { target: api, changeOrigin: true },
|
|
"/rpc": { target: api, changeOrigin: true },
|
|
},
|
|
},
|
|
};
|
|
});
|