memos/apps/memos-local-plugin/server/http.ts

243 lines
8.3 KiB
TypeScript

/**
* 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
*
* 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 agents are installed, the root path renders a small
* picker page that links to the *other* agent's URL (external link,
* no reverse proxy, no peer cores).
*/
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";
const AGENT_NAMES: readonly AgentName[] = ["openclaw", "hermes"];
/**
* 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<AgentName, number> = {
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<ServerHandle> {
const log = rootLogger.child({ channel: "server.http" });
const host = options.host ?? "127.0.0.1";
const port = options.port ?? 0;
const extraHeaders = options.extraHeaders ?? {};
const routes = buildRoutes(deps, options);
const server = createServer(async (req, res) => {
for (const [k, v] of Object.entries(extraHeaders)) {
res.setHeader(k, v);
}
try {
await dispatch(req, res, routes, deps, options, log);
} 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<void>((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;
log.info("server.started", { url, port: actualPort });
return {
url,
port: actualPort,
get closed() {
return closed;
},
async close() {
if (closed) return;
closed = true;
// Drop any idle keep-alive sockets so server.close() doesn't hang
// on pooled connections (e.g. from vitest's fetch).
try { (server as any).closeIdleConnections?.(); } catch { /* noop */ }
await new Promise<void>((resolve) => server.close(() => resolve()));
log.info("server.stopped", {});
},
};
}
async function dispatch(
req: IncomingMessage,
res: ServerResponse,
routes: ReturnType<typeof buildRoutes>,
deps: ServerDeps,
options: ServerOptions,
log: ReturnType<typeof rootLogger.child>,
): Promise<void> {
const url = new URL(req.url ?? "/", "http://localhost");
const method = (req.method ?? "GET").toUpperCase();
let pathname = url.pathname;
const selfAgent = (options.agent ?? null) as AgentName | 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_NAMES) {
const prefix = `/${name}`;
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
const tail = pathname.slice(prefix.length) || "/";
if (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;
}