import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { loadTraskHttpServerConfig } from "@openkotor/config"; import { createLogger } from "@openkotor/core"; import { JsonTraskQueryRepository, resolveDataFile } from "@openkotor/persistence"; import { buildBrowserCorsAllowedOrigins, createNodeApiHost, resolveCorsHeaders, } from "@openkotor/platform"; import { createChunkSearchProvider } from "@openkotor/retrieval"; import { createResearchWizardClient, setTraskResearchLogSink } from "@openkotor/trask"; import { createTraskHttpRouter, type TraskHttpAuth } from "@openkotor/trask-http"; import express, { type Request, type Response } from "express"; const logger = createLogger("trask-http-server"); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, "..", "..", ".."); const extractBearerToken = (authorization: string | undefined): string | null => { if (!authorization || !authorization.startsWith("Bearer ")) return null; return authorization.slice("Bearer ".length).trim() || null; }; const createWebAuth = ( config: ReturnType, ): TraskHttpAuth<{ id: string; persistQueries?: boolean }> => ({ requireAuth: (handler) => async (req: Request, res: Response) => { if (config.webApiKey) { const bearer = extractBearerToken(req.headers.authorization); const headerKey = typeof req.headers["x-trask-api-key"] === "string" ? req.headers["x-trask-api-key"].trim() : undefined; const ok = bearer === config.webApiKey || headerKey === config.webApiKey; if (!ok) { res.status(401).json({ error: "Invalid or missing API key." }); return; } await handler(req, res, { id: config.webDefaultUserId, persistQueries: true }); return; } if (config.webAllowAnonymous) { // Holocron polls GET /thread after 202 /ask; anonymous must persist queries or research never completes in-browser. await handler(req, res, { id: config.webDefaultUserId, persistQueries: true }); return; } res.status(401).json({ error: "Set TRASK_WEB_API_KEY or TRASK_WEB_ALLOW_ANONYMOUS=1 for local development.", }); }, }); const config = loadTraskHttpServerConfig(); // Resolve relative data paths from repo root so they work regardless of which // directory pnpm launches the process from (e.g. apps/trask-http-server/ vs repo root). const resolveFromRoot = (p: string) => (path.isAbsolute(p) ? p : path.resolve(repoRoot, p)); const queryRepository = new JsonTraskQueryRepository(resolveDataFile(resolveFromRoot(config.dataDir), "trask-queries.json")); /** Legacy FileChunkStore queue surface only; Holocron compose uses Chroma retrieve (`TRASK_INDEXER_BASE_URL`). */ const searchProvider = createChunkSearchProvider(resolveFromRoot(config.chunkDir)); const webResearch = createResearchWizardClient(config.researchWizard, config.ai); const researchLogVerbose = (process.env.TRASK_RESEARCH_LOG_VERBOSE ?? "").trim().toLowerCase(); if (researchLogVerbose === "1" || researchLogVerbose === "true" || researchLogVerbose === "yes") { setTraskResearchLogSink((line, level) => { if (level === "debug") { logger.debug(line); } else { logger.info(line); } }); } const runtime = { searchProvider, webResearch, queryRepository, }; const app = express(); /** In-memory Spark KV shim so qa-webui static builds stop hammering 404 on `/__spark-kv/*`. */ const sparkKvStore = new Map(); const readSparkKvBody = (req: Request): Promise => new Promise((resolve, reject) => { const chunks: Buffer[] = []; req.on("data", (chunk: Buffer) => chunks.push(chunk)); req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); req.on("error", reject); }); app.use((req, res, next) => { const pathname = (req.path.split("?")[0] ?? "").replace(/\/+$/, "") || "/"; if (!pathname.startsWith("/__spark-kv")) { next(); return; } let subpath = pathname.slice("/__spark-kv".length); if (subpath.startsWith("/")) { subpath = subpath.slice(1); } const key = subpath ? decodeURIComponent(subpath.split("/")[0]!) : ""; void (async () => { try { if (req.method === "GET" && !key) { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify([...sparkKvStore.keys()])); return; } if (req.method === "GET" && key) { const value = sparkKvStore.get(key); if (value === undefined) { res.status(404).end(); return; } res.setHeader("Content-Type", "text/plain"); res.end(value); return; } if (req.method === "POST" && key) { const body = await readSparkKvBody(req); sparkKvStore.set(key, body); res.status(200).end(); return; } if (req.method === "DELETE" && key) { sparkKvStore.delete(key); res.status(204).end(); return; } res.status(405).end(); } catch { res.status(500).end(); } })(); }); app.use(express.json()); const allowedCorsOrigins = buildBrowserCorsAllowedOrigins({ publicWebOrigin: config.publicWebOrigin, localPorts: [5174, 5173, 4174, 4173, 3000], }); app.use((req, res, next) => { const cors = resolveCorsHeaders({ method: req.method, origin: req.headers.origin }, allowedCorsOrigins, { allowHeaders: "Content-Type,Authorization,X-Trask-Api-Key", }); for (const [name, value] of Object.entries(cors.headers)) { res.setHeader(name, value); } if (cors.isPreflight) { res.sendStatus(204); return; } next(); }); app.use( "/api/trask", createTraskHttpRouter({ runtime, auth: createWebAuth(config), }), ); app.get("/health", (_req, res) => { res.status(200).json({ ok: true, service: "trask-http-server" }); }); const distFromEnv = process.env.TRASK_WEBUI_DIST_PATH?.trim(); const defaultDist = path.join(repoRoot, "apps", "holocron-web", "dist"); const webUiDist = distFromEnv ? path.resolve(distFromEnv) : defaultDist; if (existsSync(webUiDist)) { app.use(express.static(webUiDist)); app.use((req, res, next) => { if (req.method !== "GET" && req.method !== "HEAD") return next(); if (req.path.startsWith("/api") || req.path.startsWith("/__spark-kv")) return next(); res.sendFile(path.join(webUiDist, "index.html")); }); logger.info(`Serving Holocron web static files from ${webUiDist}`); } else { logger.warn(`Holocron web dist not found at ${webUiDist}; API-only mode (TRASK_WEBUI_DIST_PATH to override).`); app.get("/", (_req, res) => { res .status(200) .type("text/plain") .send("Trask HTTP API is running (API-only). Holocron static UI was not bundled; use /api/trask and /health."); }); } const { server, listen } = createNodeApiHost({ requestListener: app, createHub: () => ({}), }); listen(config.port, () => { logger.info(`Trask HTTP API listening on port ${config.port}`); }); process.on("SIGINT", () => { server.close(() => process.exit(0)); });