Files
pikasTech-unidesk/src/components/frontend/src/index.ts
T
2026-05-17 06:49:42 +00:00

775 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync } from "node:fs";
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { join } from "node:path";
import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../shared/src/rotating-jsonl";
import { notificationStyles } from "./notification-styles";
interface RuntimeConfig {
port: number;
coreInternalUrl: string;
frontendPublicUrl: string;
providerIngressPublicUrl: string;
authUsername: string;
authPassword: string;
sessionSecret: string;
sessionTtlSeconds: number;
logFile: string;
deploy: {
serviceId: string;
repo: string;
commit: string;
requestedCommit: string;
};
}
interface SessionPayload {
username: string;
expiresAt: number;
nonce: string;
}
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
interface RequestPerformanceSample {
at: string;
component: string;
method: string;
path: string;
status: number;
durationMs: number;
ok: boolean;
}
interface OperationPerformanceSample {
at: string;
service: string;
operation: string;
durationMs: number;
ok: boolean;
detail: string;
}
const sessionCookieName = "unidesk_session";
const config = readConfig();
const logger = createLogger("frontend", config.logFile);
const publicDir = join(import.meta.dir, "..", "public");
const vendorDir = join(import.meta.dir, "..", "node_modules");
const docsDir = join(import.meta.dir, "../../../..", "docs");
const appBundle = await buildFrontendApp("app.tsx");
const clientConfig = JSON.stringify({
frontendPublicUrl: config.frontendPublicUrl,
providerIngressPublicUrl: config.providerIngressPublicUrl,
authUsername: config.authUsername,
sessionTtlSeconds: config.sessionTtlSeconds,
apiBaseUrl: "/api",
});
const indexHtmlTemplate = readFileSync(join(publicDir, "index.html"), "utf8");
const indexHtmlRootMarker = '<div id="root" data-config="__UNIDESK_CONFIG__"></div>';
const baiduNetdiskDocsFallbackLinks = [
{ href: "/docs/issue/baidu-netdisk-env-setup.md", section: "Baidu Netdisk hero action / 配置与文档 SETUP", label: "Baidu Netdisk 环境变量配置" },
{ href: "/docs/issue/baidu-netdisk-user-service.md", section: "配置与文档 DESIGN", label: "Baidu Netdisk 服务方案与 API" },
{ href: "/docs/reference/microservices.md", section: "配置与文档 REF", label: "UniDesk 用户服务安全边界" },
{ href: "/docs/reference/deployment.md", section: "配置与文档 DEPLOY", label: "UniDesk 部署与重建流程" },
{ href: "/docs/reference/cli.md", section: "配置与文档 CLI", label: "UniDesk CLI 验证命令" },
];
function renderIndexHtml(extraRootAttributes = ""): string {
const docsFallback = `<nav hidden data-page="baidu-netdisk" data-section="配置与文档">${baiduNetdiskDocsFallbackLinks.map((link) => {
const href = new URL(link.href, config.frontendPublicUrl).toString();
return `<a href="${escapeHtmlAttribute(href)}" data-page-section="${escapeHtmlAttribute(link.section)}">${escapeHtmlText(link.label)}</a>`;
}).join("")}</nav>`;
const notificationStyleTag = `<style>${notificationStyles}</style>`;
const htmlWithStyles = indexHtmlTemplate.includes("</head>")
? indexHtmlTemplate.replace("</head>", `${notificationStyleTag}</head>`)
: indexHtmlTemplate;
return htmlWithStyles.replace(
indexHtmlRootMarker,
`<div id="root" data-config="${escapeHtmlAttribute(clientConfig)}"${extraRootAttributes}>${docsFallback}</div>`,
);
}
async function spaShellHtml(req: Request, pathname: string): Promise<string> {
void req;
void pathname;
// Do not inline Codex task overview JSON into the root element: task prompts
// can be very large and contain quote-heavy text, which makes malformed
// markup failures show raw JSON at the page tail. The React page fetches the
// same overview through the authenticated API after mounting.
return renderIndexHtml();
}
const requestPerformanceSamples: RequestPerformanceSample[] = [];
const operationPerformanceSamples: OperationPerformanceSample[] = [];
const maxPerformanceSamples = 3000;
async function buildFrontendApp(entrypoint: string): Promise<string> {
const result = await Bun.build({
entrypoints: [join(import.meta.dir, entrypoint)],
target: "browser",
format: "iife",
define: {
"process.env.NODE_ENV": "\"production\"",
"import.meta.env": "{\"MODE\":\"production\"}",
"import.meta.env.MODE": "\"production\"",
},
minify: true,
sourcemap: "none",
});
if (!result.success || result.outputs.length === 0) {
const messages = result.logs.map((item) => item.message).join("; ");
throw new Error(`frontend ${entrypoint} build failed: ${messages || "no output"}`);
}
return result.outputs[0].text();
}
function requiredEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value.length === 0) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function readNumberEnv(name: string): number {
const raw = requiredEnv(name);
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Environment variable ${name} must be a positive number, got ${raw}`);
}
return parsed;
}
function readConfig(): RuntimeConfig {
return {
port: readNumberEnv("PORT"),
coreInternalUrl: requiredEnv("CORE_INTERNAL_URL"),
frontendPublicUrl: requiredEnv("FRONTEND_PUBLIC_URL"),
providerIngressPublicUrl: requiredEnv("PROVIDER_INGRESS_PUBLIC_URL"),
authUsername: requiredEnv("AUTH_USERNAME"),
authPassword: requiredEnv("AUTH_PASSWORD"),
sessionSecret: requiredEnv("SESSION_SECRET"),
sessionTtlSeconds: readNumberEnv("SESSION_TTL_SECONDS"),
logFile: requiredEnv("LOG_FILE"),
deploy: {
serviceId: process.env.UNIDESK_DEPLOY_SERVICE_ID || "frontend",
repo: process.env.UNIDESK_DEPLOY_REPO || "",
commit: process.env.UNIDESK_DEPLOY_COMMIT || "",
requestedCommit: process.env.UNIDESK_DEPLOY_REQUESTED_COMMIT || "",
},
};
}
function createLogger(service: string, logFile: string) {
const writer = createHourlyJsonlWriter({
baseLogFile: logFile,
service,
maxBytes: logRetentionBytesForService(service),
});
writer.prune();
return (level: "debug" | "info" | "warn" | "error", message: string, data?: JsonValue): void => {
const entry = data === undefined
? { ts: new Date().toISOString(), service, level, message }
: { ts: new Date().toISOString(), service, level, message, data };
const line = `${JSON.stringify(entry)}\n`;
try {
writer.appendLine(line, new Date(entry.ts));
} catch (error) {
console.error(JSON.stringify({ ts: new Date().toISOString(), service, level: "error", message: "log_write_failed", data: String(error) }));
}
const consoleMethod = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
consoleMethod(line.trimEnd());
};
}
function contentType(pathname: string): string {
if (pathname.endsWith(".html")) return "text/html; charset=utf-8";
if (pathname.endsWith(".css")) return "text/css; charset=utf-8";
if (pathname.endsWith(".js")) return "text/javascript; charset=utf-8";
if (pathname.endsWith(".md")) return "text/markdown; charset=utf-8";
if (pathname.endsWith(".svg")) return "image/svg+xml";
if (pathname.endsWith(".ico")) return "image/x-icon";
return "text/plain; charset=utf-8";
}
function textResponse(req: Request, body: string, type: string): Response {
const headers = new Headers({ "content-type": type, "vary": "accept-encoding" });
if (/\bgzip\b/u.test(req.headers.get("accept-encoding") ?? "")) {
headers.set("content-encoding", "gzip");
return new Response(Bun.gzipSync(body), { headers });
}
return new Response(body, { headers });
}
function jsonResponse(body: unknown, status = 200, extraHeaders?: HeadersInit): Response {
const headers = new Headers({ "content-type": "application/json; charset=utf-8" });
if (extraHeaders !== undefined) {
new Headers(extraHeaders).forEach((value, key) => headers.set(key, value));
}
return new Response(JSON.stringify(body, null, 2), {
status,
headers,
});
}
function safePreview(value: string, max = 400): string {
const normalized = value.replace(/\s+/gu, " ").trim();
return normalized.length > max ? `${normalized.slice(0, max)}...` : normalized;
}
function trimPerformanceBuffers(): void {
while (requestPerformanceSamples.length > maxPerformanceSamples) requestPerformanceSamples.shift();
while (operationPerformanceSamples.length > maxPerformanceSamples) operationPerformanceSamples.shift();
}
function classifyRequestComponent(pathname: string): string {
if (pathname === "/api/frontend-performance") return "webui_performance";
if (pathname.startsWith("/api/") || pathname === "/logs") return "webui_api_proxy";
if (pathname === "/login" || pathname === "/logout" || pathname === "/api/session") return "webui_auth";
if (pathname === "/app.js" || pathname.startsWith("/vendor/") || /\/[^/]+\.[a-z0-9]+$/iu.test(pathname)) return "webui_static";
return "webui_page";
}
function recordRequestPerformance(req: Request, pathname: string, response: Response | undefined, durationMs: number): void {
const status = response?.status ?? 500;
requestPerformanceSamples.push({
at: new Date().toISOString(),
component: classifyRequestComponent(pathname),
method: req.method,
path: pathname,
status,
durationMs,
ok: status < 400,
});
trimPerformanceBuffers();
}
function recordOperationPerformance(service: string, operation: string, durationMs: number, ok: boolean, detail = "-"): void {
operationPerformanceSamples.push({
at: new Date().toISOString(),
service,
operation,
durationMs,
ok,
detail: detail.length > 260 ? `${detail.slice(0, 257)}...` : detail,
});
trimPerformanceBuffers();
}
function percentile(values: number[], ratio: number): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((left, right) => left - right);
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1));
return sorted[index] ?? 0;
}
function average(values: number[]): number {
if (values.length === 0) return 0;
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function roundMs(value: number): number {
return Math.round(value * 10) / 10;
}
function summarizeRequestPerformance(): JsonValue[] {
const groups = new Map<string, RequestPerformanceSample[]>();
for (const sample of requestPerformanceSamples) {
const rows = groups.get(sample.component) ?? [];
rows.push(sample);
groups.set(sample.component, rows);
}
return Array.from(groups.entries()).map(([component, rows]) => {
const durations = rows.map((row) => row.durationMs);
const failed = rows.filter((row) => !row.ok).length;
return {
component,
requestCount: rows.length,
failureCount: failed,
failureRate: rows.length === 0 ? 0 : failed / rows.length,
averageLatencyMs: roundMs(average(durations)),
p95LatencyMs: roundMs(percentile(durations, 0.95)),
maxLatencyMs: roundMs(Math.max(0, ...durations)),
};
}).sort((left, right) => Number((right as Record<string, JsonValue>).requestCount ?? 0) - Number((left as Record<string, JsonValue>).requestCount ?? 0)) as JsonValue[];
}
function summarizeOperationPerformance(): JsonValue[] {
const groups = new Map<string, OperationPerformanceSample[]>();
for (const sample of operationPerformanceSamples) {
const key = `${sample.service}:${sample.operation}`;
const rows = groups.get(key) ?? [];
rows.push(sample);
groups.set(key, rows);
}
return Array.from(groups.entries()).map(([key, rows]) => {
const [service, ...operationParts] = key.split(":");
const durations = rows.map((row) => row.durationMs);
const failed = rows.filter((row) => !row.ok).length;
return {
service,
operation: operationParts.join(":"),
count: rows.length,
failureCount: failed,
averageLatencyMs: roundMs(average(durations)),
p95LatencyMs: roundMs(percentile(durations, 0.95)),
maxLatencyMs: roundMs(Math.max(0, ...durations)),
};
}).sort((left, right) => Number((right as Record<string, JsonValue>).count ?? 0) - Number((left as Record<string, JsonValue>).count ?? 0)) as JsonValue[];
}
function frontendPerformanceResponse(req: Request): Response {
if (sessionFromRequest(req) === null) {
return jsonResponse({ ok: false, error: "authentication required" }, 401);
}
const memory = process.memoryUsage();
const recentOperationCutoff = Date.now() - 10 * 60 * 1000;
return jsonResponse({
ok: true,
service: "frontend",
generatedAt: new Date().toISOString(),
appBundleBytes: Buffer.byteLength(appBundle, "utf8"),
requests: {
sampleCount: requestPerformanceSamples.length,
componentSummary: summarizeRequestPerformance(),
recentFailures: requestPerformanceSamples.filter((sample) => !sample.ok).slice(-20).reverse(),
},
operations: {
sampleCount: operationPerformanceSamples.length,
summary: summarizeOperationPerformance(),
recentSlowOperations: operationPerformanceSamples
.filter((sample) => {
const at = Date.parse(sample.at);
return Number.isFinite(at) && at >= recentOperationCutoff;
})
.sort((left, right) => Date.parse(right.at) - Date.parse(left.at))
.slice(0, 20),
},
process: {
rssBytes: memory.rss,
heapUsedBytes: memory.heapUsed,
heapTotalBytes: memory.heapTotal,
externalBytes: memory.external,
arrayBuffersBytes: memory.arrayBuffers,
},
});
}
function escapeHtmlAttribute(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function escapeHtmlText(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function docsSlug(value: string): string {
const slug = String(value || "")
.trim()
.toLowerCase()
.replace(/[`*_~[\]().:,/\\?#%]+/gu, "")
.replace(/\s+/gu, "-")
.replace(/-+/gu, "-")
.replace(/^-+|-+$/gu, "");
return slug || "section";
}
function isSafeDocLink(url: string): boolean {
return /^https?:\/\//u.test(url) || url.startsWith("/docs/");
}
function linkifiedMarkdownInline(value: string): string {
const pattern = /(`[^`]+`|\[[^\]]+\]\((?:https?:\/\/|\/docs\/)[^) \t]+\)|https?:\/\/[^\s<>)]+|\/docs\/[^\s<>)]+)/gu;
let cursor = 0;
let html = "";
for (const match of value.matchAll(pattern)) {
const token = match[0];
const index = match.index ?? 0;
html += escapeHtmlText(value.slice(cursor, index));
cursor = index + token.length;
if (token.startsWith("`") && token.endsWith("`")) {
html += `<code>${escapeHtmlText(token.slice(1, -1))}</code>`;
continue;
}
const markdownLink = token.match(/^\[([^\]]+)\]\((.+)\)$/u);
if (markdownLink !== null) {
const [, label, href] = markdownLink;
html += isSafeDocLink(href)
? `<a href="${escapeHtmlAttribute(href)}" target="_blank" rel="noreferrer">${escapeHtmlText(label)}</a>`
: escapeHtmlText(token);
continue;
}
html += `<a href="${escapeHtmlAttribute(token)}" target="_blank" rel="noreferrer">${escapeHtmlText(token)}</a>`;
}
html += escapeHtmlText(value.slice(cursor));
return html;
}
function renderMarkdownBody(markdown: string): string {
const lines = markdown.split(/\r?\n/u);
let html = "";
let paragraph: string[] = [];
let listKind: "ul" | "ol" | null = null;
let inFence = false;
function flushParagraph(): void {
if (paragraph.length === 0) return;
html += `<p>${linkifiedMarkdownInline(paragraph.join(" "))}</p>`;
paragraph = [];
}
function closeList(): void {
if (listKind === null) return;
html += `</${listKind}>`;
listKind = null;
}
function openList(kind: "ul" | "ol"): void {
flushParagraph();
if (listKind === kind) return;
closeList();
listKind = kind;
html += `<${kind}>`;
}
for (const line of lines) {
if (/^```/u.test(line.trim())) {
flushParagraph();
closeList();
html += inFence ? "</code></pre>" : "<pre><code>";
inFence = !inFence;
continue;
}
if (inFence) {
html += `${escapeHtmlText(line)}\n`;
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+)$/u);
if (heading !== null) {
flushParagraph();
closeList();
const level = Math.min(6, heading[1].length);
const title = heading[2].trim();
html += `<h${level} id="${escapeHtmlAttribute(docsSlug(title))}">${linkifiedMarkdownInline(title)}</h${level}>`;
continue;
}
const unordered = line.match(/^\s*-\s+(.+)$/u);
if (unordered !== null) {
openList("ul");
html += `<li>${linkifiedMarkdownInline(unordered[1])}</li>`;
continue;
}
const ordered = line.match(/^\s*\d+\.\s+(.+)$/u);
if (ordered !== null) {
openList("ol");
html += `<li>${linkifiedMarkdownInline(ordered[1])}</li>`;
continue;
}
if (line.trim().length === 0) {
flushParagraph();
closeList();
continue;
}
paragraph.push(line.trim());
}
flushParagraph();
closeList();
if (inFence) html += "</code></pre>";
return html;
}
function renderDocsHtml(relativePath: string, markdown: string): string {
const firstHeading = markdown.match(/^#\s+(.+)$/mu)?.[1]?.trim();
const title = firstHeading || relativePath.split("/").pop() || "UniDesk Docs";
const escapedTitle = escapeHtmlText(title);
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapedTitle} - UniDesk Docs</title>
<style>
:root { color-scheme: dark; --bg:#0d141a; --panel:#131d26; --line:#2b3a45; --text:#d7e3e7; --muted:#81939f; --accent:#d7a13a; --accent2:#4eb7a8; }
* { box-sizing: border-box; }
body { margin:0; color:var(--text); background:radial-gradient(circle at top left, rgba(78,183,168,.12), transparent 30%), var(--bg); font:15px/1.66 "Aptos Narrow", "Noto Sans", sans-serif; }
main { width:min(980px, calc(100vw - 28px)); margin:24px auto 56px; padding:22px; border:1px solid var(--line); background:linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.015)), var(--panel); box-shadow:0 18px 46px rgba(0,0,0,.32); }
nav { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:18px; }
a { color:var(--accent2); text-decoration:none; border-bottom:1px solid rgba(78,183,168,.35); }
a:hover { color:var(--accent); border-bottom-color:var(--accent); }
nav a { padding:4px 8px; border:1px solid var(--line); background:rgba(12,18,24,.62); color:var(--text); }
h1, h2, h3, h4, h5, h6 { line-height:1.25; letter-spacing:.03em; }
h1 { margin:0 0 14px; font-size:28px; }
h2 { margin-top:28px; padding-top:16px; border-top:1px solid var(--line); color:#f1d18b; }
h3 { color:#a6ded5; }
code { padding:1px 4px; border:1px solid rgba(255,255,255,.08); background:rgba(0,0,0,.28); border-radius:3px; font-family:"Cascadia Mono","IBM Plex Mono",monospace; font-size:.9em; }
pre { overflow:auto; padding:14px; border:1px solid var(--line); background:#071016; }
pre code { padding:0; border:0; background:transparent; }
li { margin:5px 0; }
.path { margin:0 0 16px; color:var(--muted); font-size:12px; }
</style>
</head>
<body>
<main>
<nav>
<a href="/app/baidu-netdisk/">返回 Baidu Netdisk</a>
<a href="/docs/issue/baidu-netdisk-env-setup.md">环境变量配置</a>
<a href="/docs/issue/baidu-netdisk-user-service.md">服务设计文档</a>
<a href="/docs/reference/microservices.md">用户服务参考</a>
</nav>
<p class="path">${escapeHtmlText(relativePath)}</p>
${renderMarkdownBody(markdown)}
</main>
</body>
</html>`;
}
function safeDocsRelativePath(pathname: string): string | null {
const raw = pathname === "/docs" ? "" : pathname.slice("/docs/".length);
let decoded = "";
try {
decoded = decodeURIComponent(raw);
} catch {
return null;
}
if (!decoded || decoded.includes("\0") || decoded.includes("\\") || decoded.startsWith("/") || decoded.split("/").includes("..")) return null;
if (!decoded.endsWith(".md")) return null;
return decoded;
}
async function docsResponse(req: Request, url: URL): Promise<Response> {
if (req.method !== "GET" && req.method !== "HEAD") return jsonResponse({ ok: false, error: "method not allowed" }, 405);
if (sessionFromRequest(req) === null) return textResponse(req, "authentication required", "text/plain; charset=utf-8");
const relativePath = safeDocsRelativePath(url.pathname);
if (relativePath === null) return jsonResponse({ ok: false, error: "invalid docs path", path: url.pathname }, 400);
const file = Bun.file(join(docsDir, relativePath));
if (!(await file.exists())) return jsonResponse({ ok: false, error: "doc not found", path: relativePath }, 404);
if (req.method === "HEAD") return new Response(null, { headers: { "content-type": "text/html; charset=utf-8" } });
return textResponse(req, renderDocsHtml(relativePath, await file.text()), "text/html; charset=utf-8");
}
function signPayload(payload: string): string {
return createHmac("sha256", config.sessionSecret).update(payload).digest("base64url");
}
function safeEqual(a: string, b: string): boolean {
const left = Buffer.from(a);
const right = Buffer.from(b);
return left.length === right.length && timingSafeEqual(left, right);
}
function createSession(username: string): { token: string; expiresAt: number } {
const expiresAt = Date.now() + config.sessionTtlSeconds * 1000;
const payload: SessionPayload = { username, expiresAt, nonce: randomBytes(12).toString("base64url") };
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
return { token: `${encoded}.${signPayload(encoded)}`, expiresAt };
}
function verifySession(token: string | undefined): SessionPayload | null {
if (!token) return null;
const [encoded, signature, extra] = token.split(".");
if (!encoded || !signature || extra !== undefined) return null;
if (!safeEqual(signPayload(encoded), signature)) return null;
try {
const payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Partial<SessionPayload>;
if (payload.username !== config.authUsername || typeof payload.expiresAt !== "number" || payload.expiresAt <= Date.now()) return null;
if (typeof payload.nonce !== "string") return null;
return payload as SessionPayload;
} catch {
return null;
}
}
function parseCookies(header: string | null): Record<string, string> {
const cookies: Record<string, string> = {};
if (!header) return cookies;
for (const item of header.split(";")) {
const index = item.indexOf("=");
if (index === -1) continue;
const key = item.slice(0, index).trim();
const value = item.slice(index + 1).trim();
try {
cookies[key] = decodeURIComponent(value);
} catch {
cookies[key] = value;
}
}
return cookies;
}
function sessionFromRequest(req: Request): SessionPayload | null {
return verifySession(parseCookies(req.headers.get("cookie"))[sessionCookieName]);
}
function setSessionCookie(token: string): string {
return `${sessionCookieName}=${encodeURIComponent(token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${config.sessionTtlSeconds}`;
}
function clearSessionCookie(): string {
return `${sessionCookieName}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`;
}
async function readLoginBody(req: Request): Promise<{ username: string; password: string }> {
const contentTypeHeader = req.headers.get("content-type") ?? "";
if (contentTypeHeader.includes("application/x-www-form-urlencoded") || contentTypeHeader.includes("multipart/form-data")) {
const form = await req.formData();
return { username: String(form.get("username") ?? ""), password: String(form.get("password") ?? "") };
}
const body = (await req.json()) as { username?: unknown; password?: unknown };
return {
username: typeof body.username === "string" ? body.username : "",
password: typeof body.password === "string" ? body.password : "",
};
}
async function login(req: Request): Promise<Response> {
try {
const body = await readLoginBody(req);
if (body.username !== config.authUsername || body.password !== config.authPassword) {
logger("warn", "login_failed", { username: body.username });
return jsonResponse({ ok: false, error: "invalid credentials" }, 401);
}
const session = createSession(body.username);
logger("info", "login_succeeded", { username: body.username, expiresAt: new Date(session.expiresAt).toISOString() });
return jsonResponse(
{ ok: true, user: { username: body.username }, expiresAt: new Date(session.expiresAt).toISOString() },
200,
{ "set-cookie": setSessionCookie(session.token) },
);
} catch (error) {
logger("error", "login_failed", { error: error instanceof Error ? error.message : String(error) });
return jsonResponse({ ok: false, error: "invalid login request" }, 400);
}
}
function logout(): Response {
return jsonResponse({ ok: true }, 200, { "set-cookie": clearSessionCookie() });
}
function sessionResponse(req: Request): Response {
const session = sessionFromRequest(req);
if (session === null) return jsonResponse({ ok: true, authenticated: false });
return jsonResponse({ ok: true, authenticated: true, user: { username: session.username }, expiresAt: new Date(session.expiresAt).toISOString() });
}
async function proxyApi(req: Request, url: URL): Promise<Response> {
if (sessionFromRequest(req) === null) {
return jsonResponse({ ok: false, error: "authentication required" }, 401);
}
const upstreamUrl = new URL(`${url.pathname}${url.search}`, config.coreInternalUrl);
const headers = new Headers(req.headers);
headers.delete("host");
headers.delete("connection");
headers.delete("content-length");
headers.delete("cookie");
const init: RequestInit = { method: req.method, headers, redirect: "manual", signal: req.signal };
if (req.method !== "GET" && req.method !== "HEAD") {
init.body = await req.arrayBuffer();
}
let upstream: Response;
const started = performance.now();
try {
upstream = await fetch(upstreamUrl, init);
recordOperationPerformance("frontend", "core_proxy", performance.now() - started, upstream.ok, `${req.method} ${url.pathname}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
recordOperationPerformance("frontend", "core_proxy", performance.now() - started, false, message);
logger("warn", "proxy_upstream_failed", { path: url.pathname, upstreamUrl: upstreamUrl.toString(), error: message });
return jsonResponse({ ok: false, error: { message: "upstream proxy failed", detail: message } }, 502);
}
const responseHeaders = new Headers();
const upstreamContentType = upstream.headers.get("content-type");
if (upstreamContentType !== null) responseHeaders.set("content-type", upstreamContentType);
if ((upstreamContentType ?? "").toLowerCase().includes("text/event-stream")) {
responseHeaders.set("cache-control", upstream.headers.get("cache-control") || "no-store, no-transform");
responseHeaders.set("connection", "keep-alive");
const buffering = upstream.headers.get("x-accel-buffering");
if (buffering !== null) responseHeaders.set("x-accel-buffering", buffering);
return new Response(upstream.body, { status: upstream.status, headers: responseHeaders });
}
return new Response(await upstream.arrayBuffer(), { status: upstream.status, headers: responseHeaders });
}
function vendorPath(pathname: string): string | null {
if (pathname === "/vendor/react.production.min.js") return join(vendorDir, "react", "umd", "react.production.min.js");
if (pathname === "/vendor/react-dom.production.min.js") return join(vendorDir, "react-dom", "umd", "react-dom.production.min.js");
if (pathname === "/vendor/xyflow.css") return join(vendorDir, "@xyflow", "react", "dist", "style.css");
return null;
}
async function staticResponse(req: Request, pathname: string): Promise<Response> {
if (pathname === "/app.js") {
return textResponse(req, appBundle, "text/javascript; charset=utf-8");
}
const vendor = vendorPath(pathname);
const filePath = vendor ?? join(publicDir, pathname.replace(/^\/+/, ""));
const file = Bun.file(filePath);
if (!(await file.exists())) {
return jsonResponse({ ok: false, error: "not found", path: pathname }, 404);
}
return new Response(file, { headers: { "content-type": contentType(pathname) } });
}
function isStaticAssetPath(pathname: string): boolean {
return /\/[^/]+\.[a-z0-9]+$/iu.test(pathname);
}
async function handleRequest(req: Request): Promise<Response> {
const url = new URL(req.url);
logger("debug", "request", { path: url.pathname });
try {
if (url.pathname === "/health") {
return jsonResponse({ ok: true, service: "unidesk-frontend", frontendPublicUrl: config.frontendPublicUrl, deploy: config.deploy });
}
if (url.pathname === "/login" && req.method === "POST") return login(req);
if (url.pathname === "/logout" && req.method === "POST") return logout();
if (url.pathname === "/api/session") return sessionResponse(req);
if (url.pathname === "/api/frontend-performance") return frontendPerformanceResponse(req);
if (url.pathname.startsWith("/api/") || url.pathname === "/logs") return proxyApi(req, url);
if (url.pathname === "/docs" || url.pathname.startsWith("/docs/")) return docsResponse(req, url);
if (url.pathname === "/" || url.pathname === "/index.html") {
return textResponse(req, await spaShellHtml(req, url.pathname), "text/html; charset=utf-8");
}
const safePath = url.pathname.replace(/^\/+/, "");
if (safePath.includes("..") || safePath.includes("\0")) {
return jsonResponse({ ok: false, error: "invalid path" }, 400);
}
if (!isStaticAssetPath(url.pathname)) {
return textResponse(req, await spaShellHtml(req, url.pathname), "text/html; charset=utf-8");
}
return staticResponse(req, url.pathname);
} catch (error) {
logger("error", "request_failed", { path: url.pathname, error: error instanceof Error ? error.message : String(error) });
return jsonResponse({ ok: false, error: "request failed" }, 500);
}
}
const server = Bun.serve({
port: config.port,
hostname: "0.0.0.0",
idleTimeout: 120,
async fetch(req) {
const started = performance.now();
const url = new URL(req.url);
let response: Response | undefined;
try {
response = await handleRequest(req);
return response;
} finally {
recordRequestPerformance(req, url.pathname, response, performance.now() - started);
}
},
});
logger("info", "server_listening", {
url: `http://0.0.0.0:${server.port}`,
coreInternalUrl: config.coreInternalUrl,
frontendPublicUrl: config.frontendPublicUrl,
providerIngressPublicUrl: config.providerIngressPublicUrl,
logFile: config.logFile,
});