/** * Local read-only HTTP server for live RA3 Mod XML index queries. * * The server runs inside the VS Code extension host when the user has enabled * AI Agent access. It listens only on 127.0.0.1 and requires a bearer token so * unrelated local processes cannot query it by accident. * * Requests may carry `?project=` to select which project's index answers * the query. When omitted, the server falls back to the active project. Every * response echoes `index.projectDir` so the caller can verify it reached the * server/project it asked for — see docs/ai-agent-integration-plan.md §10 for * why that check is required in multi-window setups. * * Pure TypeScript: no VS Code dependency. */ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; import type { ModIndex } from "../indexer/types"; import { collectAssetReferences, type ForwardRefOptions, type XmlFileLoader, } from "./forwardRefs"; import { findAssetsLive, findDefineLive, findReferenceGroupsLive, isFileActiveLive, listAssetsByTypeLive, liveStatus, resolveIncludeLive, } from "./liveQuery"; const USAGE_GUIDE = `RA3 Mod XML live index query API Endpoints (all require Authorization: Bearer ): GET /status GET /projects GET /find_asset?id=...&type=... GET /find_references?id=...&type=... GET /get_asset_references?id=...&type=...&depth=1&targetTypes=A,B&maxEdges=200 GET /list_assets?type=...&prefix=...&limit=... GET /is_file_active?path=... GET /find_define?name=... GET /resolve_include?source=... GET /get_usage_guide All endpoints accept an optional ?project= selector. Responses echo index.projectDir; treat the result as belonging to a different project when it does not match what you asked for. `; export interface LocalServerHandle { port: number; token: string; close(): Promise; } export interface LocalServerOptions { /** Returns the current in-memory index for a project (null when unknown). */ getIndex: (projectDir?: string) => ModIndex | null; /** Every project root the live workspace currently knows about. */ listProjects?: () => string[]; /** * Reads + parses one XML file. Required by /get_asset_references, which * needs element context that the index does not store. */ loadFile?: XmlFileLoader; token?: string; /** Defaults to an OS-assigned port on 127.0.0.1. */ port?: number; } function sendJson(res: ServerResponse, status: number, value: unknown): void { const body = JSON.stringify(value); res.writeHead(status, { "content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(body), }); res.end(body); } function sendText(res: ServerResponse, status: number, text: string): void { res.writeHead(status, { "content-type": "text/plain; charset=utf-8", "content-length": Buffer.byteLength(text), }); res.end(text); } function isAuthorized(req: IncomingMessage, token: string): boolean { const header = req.headers.authorization ?? ""; return header === `Bearer ${token}`; } /** Parses a comma-separated `targetTypes` parameter. */ function parseList(raw: string | null): string[] { if (!raw) return []; return raw .split(",") .map((s) => s.trim()) .filter(Boolean); } function parseNumber(raw: string | null): number | undefined { if (raw == null || raw === "") return undefined; const value = Number(raw); return Number.isFinite(value) ? value : undefined; } function forwardRefOptionsFrom(q: URLSearchParams): ForwardRefOptions { return { depth: parseNumber(q.get("depth")), targetTypes: parseList(q.get("targetTypes")), maxEdges: parseNumber(q.get("maxEdges")), includeUnresolved: q.get("includeUnresolved") === "true", }; } async function handle( options: LocalServerOptions, token: string, req: IncomingMessage, res: ServerResponse, ): Promise { if (!isAuthorized(req, token)) { sendJson(res, 401, { error: "Unauthorized" }); return; } const url = new URL(req.url ?? "/", "http://127.0.0.1"); const q = url.searchParams; const projectDir = q.get("project") ?? undefined; const index = options.getIndex(projectDir); // When an index exists its own projectDir is authoritative; the selector is // only echoed for no-index responses so callers can still verify the server. const status = liveStatus(index, projectDir); switch (url.pathname) { case "/status": sendJson(res, 200, status); return; case "/projects": sendJson(res, 200, { index: status, data: options.listProjects?.() ?? [], }); return; case "/find_asset": sendJson(res, 200, { index: status, data: index ? findAssetsLive(index, q.get("id") ?? "", q.get("type")) : [], }); return; case "/find_references": sendJson(res, 200, { index: status, data: index ? findReferenceGroupsLive(index, q.get("id") ?? "", q.get("type")) : [], }); return; case "/list_assets": { const limit = parseNumber(q.get("limit")); sendJson(res, 200, { index: status, data: index ? listAssetsByTypeLive(index, q.get("type") ?? "", q.get("prefix") ?? "", limit) : [], }); return; } case "/is_file_active": sendJson(res, 200, { index: status, data: { active: index ? isFileActiveLive(index, q.get("path") ?? "") : false }, }); return; case "/find_define": sendJson(res, 200, { index: status, data: index ? findDefineLive(index, (q.get("name") ?? "").replace(/^\$/, "")) : [], }); return; case "/resolve_include": sendJson(res, 200, { index: status, data: index ? resolveIncludeLive(index, q.get("source") ?? "") : null, }); return; case "/get_asset_references": { if (!index) { sendJson(res, 200, { index: status, data: null, error: "get_asset_references requires a live index (VS Code must be open with the project indexed).", }); return; } if (!options.loadFile) { sendJson(res, 200, { index: status, data: null, error: "The live server was started without XML file access.", }); return; } const result = await collectAssetReferences( index, q.get("id") ?? "", q.get("type"), options.loadFile, forwardRefOptionsFrom(q), ); sendJson(res, 200, { index: status, data: result }); return; } case "/get_usage_guide": sendText(res, 200, USAGE_GUIDE); return; default: sendJson(res, 404, { error: `Not found: ${url.pathname}` }); } } /** Starts a local HTTP server; resolves once it is listening. */ export async function startLocalServer( options: LocalServerOptions, ): Promise { const token = options.token ?? randomToken(); const server = createServer((req, res) => { void handle(options, token, req, res).catch((err) => { sendJson(res, 500, { error: err instanceof Error ? err.message : String(err), }); }); }); await new Promise((resolveListen, reject) => { server.once("error", reject); server.listen(options.port ?? 0, "127.0.0.1", () => resolveListen()); }); const address = server.address() as AddressInfo; return { port: address.port, token, close: () => new Promise((resolveClose, rejectClose) => { server.close((err) => (err ? rejectClose(err) : resolveClose())); }), }; } function randomToken(): string { return `ra3-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; }