Files
Ra3ModXmlExt/src/agent/localServer.ts
T
2026-09-10 17:03:10 +02:00

269 lines
7.7 KiB
TypeScript

/**
* 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=<dir>` 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 <token>):
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=<absolute dir> 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<void>;
}
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<void> {
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<LocalServerHandle> {
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<void>((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<void>((resolveClose, rejectClose) => {
server.close((err) => (err ? rejectClose(err) : resolveClose()));
}),
};
}
function randomToken(): string {
return `ra3-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}