import http from "node:http"; import chalk from "chalk"; import { getDirname } from "../../esm-paths"; import { findAvailablePort, openBrowser, reportStudioStartupLine, resolveStudioDistPath, serveStudioAsset as serveStudioAssetFromKit, type TStudioLogFn, } from "../../studio-shared/studio-server-kit"; import { AiSessionStore } from "../ai-session-store"; import { ingestAiSessions, watchAiSessions } from "../ai-session-ingestion"; import { handleAiStudioApi } from "./ai-studio-routes"; import { handlePluginsApi } from "./plugins-routes"; import { handleNexusApi } from "../../nexus/studio/nexus-routes"; export type TAiStudioServerOptions = { port?: number; apiOnly?: boolean; onLog?: TStudioLogFn; }; export type TAiStudioServerHandle = { url: string; port: number; close: () => Promise; }; // The React app is built as a second Vite entry point alongside DB Studio's, into the SAME // dist/core/db/studio-dist directory (see studio/vite.config.ts) — cheaper than a second dist // folder, and safe because Vite hashes each entry's asset filenames independently. This server // just requests ai-studio.html as its SPA-fallback root instead of index.html. const AI_STUDIO_HTML = "ai-studio.html"; const __dirname = getDirname(import.meta.url); const AI_STUDIO_NOT_BUILT_HTML = "

AI Studio UI is not built

" + "

Run npm run build:studio (or npm run build) from the repository root, then restart smdg ai studio.

"; async function serveAiStudioAsset(pathname: string, res: http.ServerResponse): Promise { await serveStudioAssetFromKit({ distPath: await resolveStudioDistPath(__dirname), pathname, res, fallbackHtmlFileName: AI_STUDIO_HTML, notBuiltMessageHtml: AI_STUDIO_NOT_BUILT_HTML, }); } export async function startAiStudioServer(options: TAiStudioServerOptions = {}): Promise { const preferredPort = options.port && options.port > 0 ? options.port : 45889; const port = await findAvailablePort(preferredPort); const store = await AiSessionStore.open(); if (store) { // Ingest once on startup so the first page load already has data, then keep watching for // live session activity (debounced) so Studio updates without a manual refresh. await ingestAiSessions(store).catch(() => undefined); } const watcher = store ? watchAiSessions(() => { void ingestAiSessions(store).catch(() => undefined); }) : undefined; const server = http.createServer((req, res) => { void (async () => { try { const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`); const pathname = url.pathname; const method = req.method ?? "GET"; // Plugin management is independent of the SQLite store — it must keep working on // Node < 22.5, where handleAiStudioApi 503s every /api/ai/* route. Nexus (Code // Intelligence) routes are mounted here too, not a 4th dedicated server — the // session-comparison route needs both `store` and GitNexus data in one handler. const handled = (await handleAiStudioApi(req, res, url, store)) || (await handlePluginsApi(req, res, url)) || (await handleNexusApi(req, res, url, store)); if (handled) return; if (pathname === "/" && method === "GET") { if (options.apiOnly) { res.writeHead(404, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ error: "This server is running in --api-only mode." })); return; } await serveAiStudioAsset(pathname, res); return; } if (method === "GET" && !pathname.startsWith("/api/") && !options.apiOnly) { await serveAiStudioAsset(pathname, res); return; } res.writeHead(404, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ error: "Not found" })); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (!res.headersSent) { res.writeHead(500, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ error: message })); } else { res.end(); } } })(); }); await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve)); const url = `http://127.0.0.1:${port}`; reportStudioStartupLine(options.onLog, `SimpleMDG AI Studio: ${url}`, chalk.green); if (!store) { reportStudioStartupLine( options.onLog, "node:sqlite is unavailable (requires Node.js 22.5+). Sessions cannot be stored; upgrade Node and restart.", chalk.yellow, ); } reportStudioStartupLine(options.onLog, "Server is bound to 127.0.0.1 only. Press Ctrl+C to stop.", chalk.gray); if (!options.apiOnly && !process.env.SMDG_AI_STUDIO_NO_OPEN) { await openBrowser(url); } return { url, port, close: async () => { watcher?.dispose(); store?.close(); // server.close()'s callback only fires once every open socket disconnects — the // browser tab opened by openBrowser() holds a keep-alive connection, so without // forcing existing sockets closed this would hang forever and Ctrl+C would never exit. await new Promise((resolve) => { server.close(() => resolve()); server.closeAllConnections(); }); }, }; }