269 lines
7.7 KiB
TypeScript
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)}`;
|
|
}
|