/** * HTTP server entry point. * * Built on the Node standard library's `http` module — no framework. We * pay the small cost of writing a router by hand to keep the surface * area tiny, which in turn lets us guarantee the security properties * spelled out in `ALGORITHMS.md` (loopback default, API-key gating, * static-root escape prevention, etc.). * * ## Single-agent URL layout * * Each agent runs its own viewer on its own port: * * - openclaw → :18799 * - hermes → :18800 * - deepseek-harness → :18801 * * The server hosts the SPA at `/`, the JSON REST API at `/api/v1/*`, * and the static viewer assets. There are no `/openclaw/*` / * `/hermes/*` URL prefixes — clients always talk to the agent's own * port. If both OpenClaw and Hermes are installed, their root path renders a * small picker page that links to the *other* agent's URL (external link, no * reverse proxy, no peer cores). */ import { randomUUID } from "node:crypto"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { rootLogger } from "../core/logger/index.js"; import { buildRoutes } from "./routes/registry.js"; import { readBody, writeJson, writeNotFound, writeMethodNotAllowed } from "./middleware/io.js"; import { enforceApiKey } from "./middleware/auth.js"; import { requireSession } from "./routes/auth.js"; import { serveStatic } from "./middleware/static.js"; import type { ServerDeps, ServerHandle, ServerOptions } from "./types.js"; type AgentName = "openclaw" | "hermes"; type AgentPrefix = AgentName | "memos"; const AGENT_PREFIXES: readonly AgentPrefix[] = ["openclaw", "hermes", "memos"]; /** * Well-known per-agent viewer port. The picker page links to the * peer agent here. If a user moves a peer to a non-default port the * link will 404 — that's the intended trade-off for keeping the picker * fully static (no IPC, no port scanning). */ const AGENT_DEFAULT_PORTS: Record = { openclaw: 18799, hermes: 18800, }; const DEFAULT_MAX_BODY_BYTES = 1_048_576; const IMPORT_MAX_BODY_BYTES = 64 * 1024 * 1024; export async function startHttpServer( deps: ServerDeps, options: ServerOptions = {}, ): Promise { const runtimeOptions: ServerOptions = { ...options, instanceId: options.instanceId?.trim() || randomUUID(), }; const log = rootLogger.child({ channel: "server.http" }); const host = runtimeOptions.host ?? "127.0.0.1"; const port = runtimeOptions.port ?? 0; const extraHeaders = runtimeOptions.extraHeaders ?? {}; const routes = buildRoutes(deps, runtimeOptions); const closeActiveSseOnShutdown = runtimeOptions.closeActiveSseOnShutdown ?? false; const activeSseResponses = new Set(); let closing = false; const trackSseResponse = (req: IncomingMessage, res: ServerResponse): void => { if (!closeActiveSseOnShutdown) return; if (!isSseResponse(req, res, runtimeOptions.agent) || res.destroyed || res.writableEnded) { return; } if (closing) { res.destroy(); return; } activeSseResponses.add(res); const forget = () => activeSseResponses.delete(res); res.once("close", forget); res.once("finish", forget); }; const server = createServer(async (req, res) => { res.once("finish", () => { // A request that was active when close() began has just become idle. // Drop only that keep-alive state; never terminate the handler early. if (closing) server.closeIdleConnections(); }); for (const [k, v] of Object.entries(extraHeaders)) { res.setHeader(k, v); } try { await dispatch(req, res, routes, deps, runtimeOptions, log); trackSseResponse(req, res); } catch (err) { const msg = err instanceof Error ? err.message : String(err); log.error("request.unhandled", { path: req.url, err: msg }); if (!res.headersSent) { writeJson(res, 500, { error: { code: "internal", message: msg } }); } try { res.end(); } catch { // best-effort — connection may already be closed } } }); // Single bind attempt. EADDRINUSE is propagated so the caller // (`bridge.cts` / `adapters/openclaw`) can log it and run headless. await new Promise((resolve, reject) => { const onErr = (e: NodeJS.ErrnoException) => reject(e); server.once("error", onErr); server.listen(port, host, () => { server.off("error", onErr); resolve(); }); }); const addr = server.address(); const actualPort = typeof addr === "object" && addr ? addr.port : port; const url = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${actualPort}`; let closed = false; let closePromise: Promise | null = null; log.info("server.started", { url, port: actualPort }); return { url, port: actualPort, get closed() { return closed; }, close() { if (closePromise) return closePromise; closePromise = (async () => { closing = true; // Stop accepting first, then, when opted in, terminate only // long-lived SSE responses. // Ordinary in-flight HTTP handlers are allowed to finish before the // server closes so they cannot race memory-core/SQLite shutdown. const stopped = new Promise((resolve) => server.close(() => resolve())); server.closeIdleConnections(); if (closeActiveSseOnShutdown) { for (const response of activeSseResponses) response.destroy(); } await stopped; closed = true; log.info("server.stopped", {}); })(); return closePromise; }, }; } function isSseResponse( req: IncomingMessage, res: ServerResponse, selfAgent: ServerOptions["agent"], ): boolean { if ((req.method ?? "GET").toUpperCase() !== "GET" || res.statusCode !== 200) { return false; } let pathname = new URL(req.url ?? "/", "http://localhost").pathname; for (const name of AGENT_PREFIXES) { const prefix = `/${name}`; if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) continue; if (name !== "memos" && name !== selfAgent) return false; pathname = pathname.slice(prefix.length) || "/"; break; } return pathname === "/api/v1/events" || pathname === "/api/v1/logs"; } async function dispatch( req: IncomingMessage, res: ServerResponse, routes: ReturnType, deps: ServerDeps, options: ServerOptions, log: ReturnType, ): Promise { const url = new URL(req.url ?? "/", "http://localhost"); const method = (req.method ?? "GET").toUpperCase(); let pathname = url.pathname; const selfAgent = options.agent ?? null; // Backwards-compat for the old single-port "hub/peer" layout, where // every URL was prefixed (`/openclaw/api/v1/...` or // `/hermes/...`). New installs serve the SPA at root, but old // bookmarks (and the old `AGENT_PREFIX` baked into older viewer // bundles) still hit the prefixed paths. // // Two cases: // - Prefix matches THIS agent → drop the prefix and continue // dispatching internally. We must NOT 302 here, because POST / // PATCH / DELETE get downgraded to GET on a 302 in most browsers, // which would silently corrupt mutations. // - Prefix matches the OTHER agent → that agent lives on its own // port now, so 302 the user there. (We accept the request-method // downgrade because cross-port redirects are inherently a "follow // this link" gesture; the SPA bundle on the other port re-issues // mutations from form state, not from the original POST body.) for (const name of AGENT_PREFIXES) { const prefix = `/${name}`; if (pathname === prefix || pathname.startsWith(`${prefix}/`)) { const tail = pathname.slice(prefix.length) || "/"; if (name === "memos" || name === selfAgent) { pathname = tail; break; } const peerPort = AGENT_DEFAULT_PORTS[name]; const targetHost = req.headers["host"]?.split(":")[0] ?? "127.0.0.1"; res.writeHead(302, { Location: `http://${targetHost}:${peerPort}${tail}${url.search}`, }); res.end(); return; } } // Static assets first — cheapest path. Serve on GET/HEAD only. // The root path falls through to the static handler which serves // `index.html` (the SPA). There is no picker page: each agent owns // its own port and is reachable directly. The SPA's header probes // the peer's well-known port and surfaces a small link if it's up. if ((method === "GET" || method === "HEAD") && !pathname.startsWith("/api/")) { const served = await serveStatic(res, pathname, options); if (served) return; } // API key gating — applies to every /api/* route (host-configured). if (pathname.startsWith("/api/") && options.apiKey) { const allowed = enforceApiKey(req, res, options.apiKey); if (!allowed) return; } // Session-cookie gating — applies only when the operator has // enabled password protection (i.e. `~/.../memos-plugin/.auth.json` // exists). Auth endpoints + `/health` are explicitly allowed so // the viewer can complete login even from a locked state. if (pathname.startsWith("/api/") && deps.home?.root) { const ok = requireSession( req, res, String(deps.home.root), pathname, selfAgent, ); if (!ok) return; } // Flat router lookup. const key = `${method} ${pathname}`; const exact = routes.getExact(key); if (exact) { const body = await readBody(req, bodyLimitForPath(pathname, options.maxBodyBytes)); const result = await exact({ req, res, url, body, deps, params: {} }); if (!res.headersSent && result !== undefined) { writeJson(res, 200, result); } return; } // Pattern-route fallback (e.g. `/api/v1/traces/:id`). const pattern = routes.matchPattern(method, pathname); if (pattern) { const body = await readBody(req, bodyLimitForPath(pathname, options.maxBodyBytes)); const result = await pattern.handler({ req, res, url, body, deps, params: pattern.params, }); if (!res.headersSent && result !== undefined) { writeJson(res, 200, result); } return; } // Differentiate "route exists, wrong method" from "no such route". if (routes.pathMatches(pathname)) { writeMethodNotAllowed(res, method); return; } writeNotFound(res); log.debug("route.not_found", { path: pathname, method }); void deps; } function bodyLimitForPath(pathname: string, configured?: number): number { if (configured !== undefined) return configured; return pathname === "/api/v1/import" ? IMPORT_MAX_BODY_BYTES : DEFAULT_MAX_BODY_BYTES; }