Files
clawdbot/src/webchat/server.ts
2025-12-09 23:16:57 +01:00

153 lines
4.2 KiB
TypeScript

import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadConfig } from "../config/config.js";
import { logDebug, logError } from "../logger.js";
const WEBCHAT_DEFAULT_PORT = 18788;
type WebChatServerState = {
server: http.Server;
port: number;
};
let state: WebChatServerState | null = null;
function resolveWebRoot() {
const here = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
// Bundled inside Clawdis.app: .../Contents/Resources/WebChat
path.resolve(here, "../../../WebChat"),
// When running from repo without bundling
path.resolve(here, "../../WebChat"),
// Fallback to source tree location
path.resolve(here, "../../apps/macos/Sources/Clawdis/Resources/WebChat"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
throw new Error(`webchat assets not found; tried: ${candidates.join(", ")}`);
}
function notFound(res: http.ServerResponse) {
res.statusCode = 404;
res.end("Not Found");
}
export async function startWebChatServer(
port = WEBCHAT_DEFAULT_PORT,
): Promise<WebChatServerState | null> {
if (state) return state;
const root = resolveWebRoot();
const server = http.createServer(async (req, res) => {
if (!req.url) return notFound(res);
if (
req.socket.remoteAddress &&
!req.socket.remoteAddress.startsWith("127.")
) {
res.statusCode = 403;
res.end("loopback only");
return;
}
const url = new URL(req.url, "http://127.0.0.1");
if (url.pathname.startsWith("/webchat")) {
let rel = url.pathname.replace(/^\/webchat\/?/, "");
if (!rel || rel.endsWith("/")) rel = `${rel}index.html`;
const filePath = path.join(root, rel);
if (!filePath.startsWith(root) || !fs.existsSync(filePath)) {
return notFound(res);
}
const data = fs.readFileSync(filePath);
const ext = path.extname(filePath).toLowerCase();
const type =
ext === ".html"
? "text/html"
: ext === ".js"
? "application/javascript"
: ext === ".css"
? "text/css"
: "application/octet-stream";
res.setHeader("Content-Type", type);
res.end(data);
return;
}
if (url.pathname === "/") {
const filePath = path.join(root, "index.html");
const data = fs.readFileSync(filePath);
res.setHeader("Content-Type", "text/html");
res.end(data);
return;
}
const relPath = url.pathname.replace(/^\//, "");
if (relPath) {
const filePath = path.join(root, relPath);
if (filePath.startsWith(root) && fs.existsSync(filePath)) {
const data = fs.readFileSync(filePath);
res.setHeader("Content-Type", "application/octet-stream");
res.end(data);
return;
}
}
notFound(res);
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve());
}).catch((err) => {
const code = (err as NodeJS.ErrnoException).code;
const msg = code ? `${code}: ${String(err)}` : String(err);
logError(
`webchat server failed to bind 127.0.0.1:${port} (${msg}); continuing without webchat`,
);
});
state = { server, port };
logDebug(`webchat server listening on 127.0.0.1:${port}`);
return state;
}
export async function stopWebChatServer() {
if (!state) return;
if (state.server) {
await new Promise<void>((resolve) => state?.server.close(() => resolve()));
}
state = null;
}
// Legacy no-op: gateway readiness is now handled directly by clients.
export async function waitForWebChatGatewayReady() {
return;
}
export function __forceWebChatSnapshotForTests() {
// no-op: snapshots now come from the Gateway WS directly.
}
export async function __broadcastGatewayEventForTests() {
// no-op
}
export async function ensureWebChatServerFromConfig() {
const cfg = loadConfig();
if (cfg.webchat?.enabled === false) return null;
const port = cfg.webchat?.port ?? WEBCHAT_DEFAULT_PORT;
try {
return await startWebChatServer(port);
} catch (err) {
logDebug(`webchat server failed to start: ${String(err)}`);
throw err;
}
}