server.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521
  1. /**
  2. * CodeGraph Visualizer Server
  3. *
  4. * Lightweight HTTP server that serves the graph visualization UI
  5. * and exposes REST API endpoints for querying the CodeGraph database.
  6. */
  7. import * as http from 'http';
  8. import * as fs from 'fs';
  9. import * as path from 'path';
  10. import * as url from 'url';
  11. import { execFile } from 'child_process';
  12. import type CodeGraph from '../index';
  13. import type { Node, Edge, NodeKind } from '../types';
  14. export interface VisualizerOptions {
  15. /** Port to listen on (0 = auto-assign) */
  16. port?: number;
  17. /** Whether to open browser automatically */
  18. openBrowser?: boolean;
  19. /** Host to bind to */
  20. host?: string;
  21. }
  22. /**
  23. * Serialize a Subgraph (which uses Map) to plain JSON
  24. */
  25. function serializeSubgraph(subgraph: { nodes: Map<string, Node>; edges: Edge[]; roots: string[] }) {
  26. return {
  27. nodes: Array.from(subgraph.nodes.values()),
  28. edges: subgraph.edges,
  29. roots: subgraph.roots,
  30. };
  31. }
  32. export class VisualizerServer {
  33. private cg: CodeGraph;
  34. private server: http.Server | null = null;
  35. private projectRoot: string;
  36. private symbolIndexCache: string | null = null;
  37. private claudeAvailable: boolean | null = null;
  38. constructor(cg: CodeGraph) {
  39. this.cg = cg;
  40. this.projectRoot = cg.getProjectRoot();
  41. }
  42. /**
  43. * Build a compact symbol index string for Claude prompts
  44. */
  45. private buildSymbolIndex(): string {
  46. if (this.symbolIndexCache) return this.symbolIndexCache;
  47. const validKinds: NodeKind[] = ['function', 'method', 'class', 'interface', 'component', 'route', 'enum', 'type_alias'];
  48. const byFile = new Map<string, string[]>();
  49. for (const kind of validKinds) {
  50. for (const node of this.cg.getNodesByKind(kind)) {
  51. const symbols = byFile.get(node.filePath) || [];
  52. symbols.push(`${node.kind}:${node.name}`);
  53. byFile.set(node.filePath, symbols);
  54. }
  55. }
  56. const lines: string[] = [];
  57. for (const [file, symbols] of byFile) {
  58. lines.push(`${file}: ${symbols.join(', ')}`);
  59. }
  60. this.symbolIndexCache = lines.join('\n');
  61. return this.symbolIndexCache;
  62. }
  63. /**
  64. * Ask Claude CLI to interpret a natural language question into relevant symbol names
  65. */
  66. private async askClaude(question: string): Promise<string[] | null> {
  67. // Check if claude is available (cache result)
  68. if (this.claudeAvailable === false) return null;
  69. const symbolIndex = this.buildSymbolIndex();
  70. const prompt = `Given the question and codebase symbol index below, identify the single best ENTRY POINT symbol — the one function, component, or route handler where this flow starts.
  71. Rules:
  72. - Pick ONE symbol that is the starting point a user or request would hit first
  73. - Prefer page components, route handlers, or top-level functions
  74. - Do NOT pick utility functions, helpers, or middleware
  75. Return ONLY this JSON, nothing else:
  76. {"entry": "symbolName"}
  77. Question: "${question}"
  78. Symbol index:
  79. ${symbolIndex}`;
  80. return new Promise((resolve) => {
  81. const timeout = setTimeout(() => {
  82. resolve(null);
  83. }, 30000);
  84. execFile('claude', ['-p', prompt, '--output-format', 'text'], {
  85. timeout: 30000,
  86. maxBuffer: 1024 * 1024,
  87. }, (err, stdout) => {
  88. clearTimeout(timeout);
  89. if (err) {
  90. this.claudeAvailable = false;
  91. resolve(null);
  92. return;
  93. }
  94. this.claudeAvailable = true;
  95. // Parse Claude's response — try object format first, then array fallback
  96. try {
  97. const text = stdout.trim();
  98. // Try to extract JSON object {"entry": ..., "flow": [...]}
  99. const objMatch = text.match(/\{[\s\S]*\}/);
  100. if (objMatch) {
  101. const parsed = JSON.parse(objMatch[0]) as { entry?: string; flow?: string[] };
  102. if (parsed.flow && Array.isArray(parsed.flow) && parsed.flow.length > 0) {
  103. // Return flow with entry first
  104. const names = parsed.flow.map(String);
  105. if (parsed.entry && !names.includes(parsed.entry)) {
  106. names.unshift(String(parsed.entry));
  107. }
  108. resolve(names);
  109. return;
  110. }
  111. }
  112. // Fallback: try JSON array
  113. const arrMatch = text.match(/\[[\s\S]*\]/);
  114. if (arrMatch) {
  115. const names = JSON.parse(arrMatch[0]) as string[];
  116. if (Array.isArray(names) && names.length > 0) {
  117. resolve(names.map(String));
  118. return;
  119. }
  120. }
  121. } catch {
  122. // Parse failed
  123. }
  124. resolve(null);
  125. });
  126. });
  127. }
  128. /**
  129. * Start the visualizer server
  130. */
  131. async start(options: VisualizerOptions = {}): Promise<{ port: number; url: string }> {
  132. const host = options.host || '127.0.0.1';
  133. const port = options.port || 0;
  134. this.server = http.createServer((req, res) => {
  135. this.handleRequest(req, res).catch((err) => {
  136. console.error('[Visualizer] Request error:', err);
  137. res.writeHead(500, { 'Content-Type': 'application/json' });
  138. res.end(JSON.stringify({ error: 'Internal server error' }));
  139. });
  140. });
  141. return new Promise((resolve, reject) => {
  142. this.server!.listen(port, host, () => {
  143. const addr = this.server!.address();
  144. if (!addr || typeof addr === 'string') {
  145. reject(new Error('Failed to get server address'));
  146. return;
  147. }
  148. const serverUrl = `http://${host}:${addr.port}`;
  149. resolve({ port: addr.port, url: serverUrl });
  150. });
  151. this.server!.on('error', reject);
  152. });
  153. }
  154. /**
  155. * Stop the server
  156. */
  157. stop(): Promise<void> {
  158. return new Promise((resolve) => {
  159. if (this.server) {
  160. this.server.close(() => resolve());
  161. } else {
  162. resolve();
  163. }
  164. });
  165. }
  166. private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
  167. const parsedUrl = url.parse(req.url || '/', true);
  168. const pathname = parsedUrl.pathname || '/';
  169. // CORS headers for local development
  170. res.setHeader('Access-Control-Allow-Origin', '*');
  171. res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
  172. res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
  173. if (req.method === 'OPTIONS') {
  174. res.writeHead(204);
  175. res.end();
  176. return;
  177. }
  178. // API routes
  179. if (pathname.startsWith('/api/')) {
  180. return this.handleAPI(pathname, parsedUrl.query as Record<string, string>, res);
  181. }
  182. // Static file serving
  183. return this.serveStatic(pathname, res);
  184. }
  185. private async handleAPI(
  186. pathname: string,
  187. query: Record<string, string>,
  188. res: http.ServerResponse
  189. ): Promise<void> {
  190. const json = (data: unknown, status = 200) => {
  191. res.writeHead(status, { 'Content-Type': 'application/json' });
  192. res.end(JSON.stringify(data));
  193. };
  194. try {
  195. // GET /api/status
  196. if (pathname === '/api/status') {
  197. const stats = this.cg.getStats();
  198. json({ stats, projectRoot: this.projectRoot, projectName: path.basename(this.projectRoot) });
  199. return;
  200. }
  201. // GET /api/embeddings/status
  202. if (pathname === '/api/embeddings/status') {
  203. const embeddingStats = this.cg.getEmbeddingStats();
  204. const isInitialized = this.cg.isEmbeddingsInitialized();
  205. const totalVectors = embeddingStats?.totalVectors ?? 0;
  206. const stats = this.cg.getStats();
  207. // Consider ready if we have vectors for at least half the eligible nodes
  208. const eligibleNodes = stats.nodeCount - (stats.nodesByKind.file ?? 0) - (stats.nodesByKind.import ?? 0);
  209. const isReady = totalVectors > 0 && totalVectors >= eligibleNodes * 0.5;
  210. json({ isEnabled: true, isInitialized, isReady, totalVectors, eligibleNodes });
  211. return;
  212. }
  213. // GET /api/embeddings/generate — SSE stream that enables, initializes, and generates embeddings
  214. if (pathname === '/api/embeddings/generate') {
  215. res.writeHead(200, {
  216. 'Content-Type': 'text/event-stream',
  217. 'Cache-Control': 'no-cache',
  218. 'Connection': 'keep-alive',
  219. });
  220. const send = (event: string, data: unknown) => {
  221. res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
  222. };
  223. try {
  224. // Step 1: Initialize embedding model (downloads on first use)
  225. send('status', { phase: 'model', message: 'Loading embedding model (first time may download ~30MB)...' });
  226. await this.cg.initializeEmbeddings();
  227. send('status', { phase: 'model', message: 'Embedding model ready' });
  228. // Step 3: Generate embeddings with progress
  229. send('status', { phase: 'embedding', message: 'Generating embeddings...' });
  230. const count = await this.cg.generateEmbeddings((progress) => {
  231. send('progress', {
  232. current: progress.current,
  233. total: progress.total,
  234. nodeName: progress.nodeName,
  235. percent: progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0,
  236. });
  237. });
  238. send('complete', { totalEmbedded: count, message: `Generated ${count} embeddings` });
  239. } catch (err) {
  240. const message = err instanceof Error ? err.message : String(err);
  241. send('error', { message });
  242. }
  243. res.end();
  244. return;
  245. }
  246. // GET /api/search?q=...&kind=...&limit=...
  247. if (pathname === '/api/search') {
  248. const q = query.q || '';
  249. const kind = query.kind as NodeKind | undefined;
  250. const limit = parseInt(query.limit || '30', 10);
  251. if (!q) {
  252. json({ results: [] });
  253. return;
  254. }
  255. const results = this.cg.searchNodes(q, { kinds: kind ? [kind] : undefined, limit });
  256. json({ results });
  257. return;
  258. }
  259. // GET /api/explore?q=...
  260. // Find the best entry point, then return its call graph
  261. if (pathname === '/api/explore') {
  262. const q = query.q || '';
  263. if (!q) {
  264. json({ nodes: [], edges: [], roots: [], entryPoint: null });
  265. return;
  266. }
  267. let entryNodeId: string | null = null;
  268. let usedClaude = false;
  269. const validKinds: NodeKind[] = ['function', 'method', 'class', 'interface', 'component', 'route'];
  270. // Try Claude CLI to find the best entry point
  271. const claudeNames = await this.askClaude(q);
  272. if (claudeNames && claudeNames.length > 0) {
  273. usedClaude = true;
  274. // Find the entry point in the graph
  275. for (const name of claudeNames) {
  276. if (entryNodeId) break;
  277. const results = this.cg.searchNodes(name, { kinds: validKinds, limit: 3 });
  278. for (const r of results) {
  279. if (r.node.name.toLowerCase() === name.toLowerCase() ||
  280. r.node.name.toLowerCase().includes(name.toLowerCase()) ||
  281. name.toLowerCase().includes(r.node.name.toLowerCase())) {
  282. entryNodeId = r.node.id;
  283. break;
  284. }
  285. }
  286. }
  287. }
  288. // Keyword fallback: find best match from query keywords
  289. if (!entryNodeId) {
  290. const stopWords = new Set(['how', 'does', 'what', 'the', 'is', 'a', 'an', 'and', 'or', 'in', 'to', 'for', 'of', 'with', 'when', 'do', 'it', 'my', 'work', 'works', 'about', 'show', 'me']);
  291. const keywords = q.toLowerCase().split(/\s+/)
  292. .map(w => w.replace(/[^a-z0-9]/g, ''))
  293. .filter(w => w.length >= 2 && !stopWords.has(w));
  294. for (const kw of keywords) {
  295. if (entryNodeId) break;
  296. const results = this.cg.searchNodes(kw, { kinds: validKinds, limit: 5 });
  297. if (results.length > 0) {
  298. entryNodeId = results[0]!.node.id;
  299. }
  300. }
  301. }
  302. if (!entryNodeId) {
  303. json({ nodes: [], edges: [], roots: [], entryPoint: null });
  304. return;
  305. }
  306. // Get the call graph from this entry point (depth 3)
  307. const callGraph = this.cg.getCallGraph(entryNodeId, 3);
  308. const result = serializeSubgraph(callGraph);
  309. json({
  310. nodes: result.nodes,
  311. edges: result.edges,
  312. roots: [entryNodeId],
  313. entryPoint: entryNodeId,
  314. usedClaude,
  315. });
  316. return;
  317. }
  318. // GET /api/overview?limit=...
  319. if (pathname === '/api/overview') {
  320. const limit = parseInt(query.limit || '50', 10);
  321. // Get top-level exported classes, functions, components
  322. const kinds: NodeKind[] = ['class', 'function', 'interface', 'component', 'enum', 'type_alias'];
  323. const nodes: Node[] = [];
  324. for (const kind of kinds) {
  325. const kindNodes = this.cg.getNodesByKind(kind);
  326. for (const n of kindNodes) {
  327. if (n.isExported || n.kind === 'class' || n.kind === 'component') {
  328. nodes.push(n);
  329. }
  330. if (nodes.length >= limit) break;
  331. }
  332. if (nodes.length >= limit) break;
  333. }
  334. json({ nodes });
  335. return;
  336. }
  337. // GET /api/files
  338. if (pathname === '/api/files') {
  339. const files = this.cg.getFiles();
  340. json({ files });
  341. return;
  342. }
  343. // Routes with node ID: /api/node/<id>/...
  344. const nodeMatch = pathname.match(/^\/api\/node\/([^/]+)(\/.*)?$/);
  345. if (nodeMatch) {
  346. const nodeId = decodeURIComponent(nodeMatch[1]!);
  347. const sub = nodeMatch[2] || '';
  348. // GET /api/node/<id>
  349. if (!sub || sub === '/') {
  350. const node = this.cg.getNode(nodeId);
  351. if (!node) {
  352. json({ error: 'Node not found' }, 404);
  353. return;
  354. }
  355. const code = await this.cg.getCode(nodeId);
  356. const ancestors = this.cg.getAncestors(nodeId);
  357. json({ node, code, ancestors });
  358. return;
  359. }
  360. // GET /api/node/<id>/callers?depth=...
  361. if (sub === '/callers') {
  362. const depth = parseInt(query.depth || '1', 10);
  363. const items = this.cg.getCallers(nodeId, depth);
  364. json({ items });
  365. return;
  366. }
  367. // GET /api/node/<id>/callees?depth=...
  368. if (sub === '/callees') {
  369. const depth = parseInt(query.depth || '1', 10);
  370. const items = this.cg.getCallees(nodeId, depth);
  371. json({ items });
  372. return;
  373. }
  374. // GET /api/node/<id>/children
  375. if (sub === '/children') {
  376. const children = this.cg.getChildren(nodeId);
  377. json({ children });
  378. return;
  379. }
  380. // GET /api/node/<id>/impact?depth=...
  381. if (sub === '/impact') {
  382. const depth = parseInt(query.depth || '2', 10);
  383. const subgraph = this.cg.getImpactRadius(nodeId, depth);
  384. json(serializeSubgraph(subgraph));
  385. return;
  386. }
  387. // GET /api/node/<id>/callgraph?depth=...
  388. if (sub === '/callgraph') {
  389. const depth = parseInt(query.depth || '2', 10);
  390. const subgraph = this.cg.getCallGraph(nodeId, depth);
  391. json(serializeSubgraph(subgraph));
  392. return;
  393. }
  394. // GET /api/node/<id>/context
  395. if (sub === '/context') {
  396. const context = this.cg.getContext(nodeId);
  397. json({ context });
  398. return;
  399. }
  400. json({ error: 'Unknown endpoint' }, 404);
  401. return;
  402. }
  403. // GET /api/file-nodes?path=...
  404. if (pathname === '/api/file-nodes') {
  405. const filePath = query.path || '';
  406. if (!filePath) {
  407. json({ error: 'path parameter required' }, 400);
  408. return;
  409. }
  410. const nodes = this.cg.getNodesInFile(filePath);
  411. json({ nodes });
  412. return;
  413. }
  414. json({ error: 'Unknown API endpoint' }, 404);
  415. } catch (err) {
  416. const message = err instanceof Error ? err.message : String(err);
  417. json({ error: message }, 500);
  418. }
  419. }
  420. private serveStatic(pathname: string, res: http.ServerResponse): void {
  421. if (pathname === '/' || pathname === '/index.html') {
  422. pathname = '/index.html';
  423. }
  424. // Resolve from the public directory next to this file
  425. const publicDir = path.join(__dirname, 'public');
  426. const filePath = path.join(publicDir, pathname);
  427. // Security: prevent directory traversal
  428. if (!filePath.startsWith(publicDir)) {
  429. res.writeHead(403);
  430. res.end('Forbidden');
  431. return;
  432. }
  433. const ext = path.extname(filePath).toLowerCase();
  434. const mimeTypes: Record<string, string> = {
  435. '.html': 'text/html',
  436. '.css': 'text/css',
  437. '.js': 'application/javascript',
  438. '.json': 'application/json',
  439. '.png': 'image/png',
  440. '.svg': 'image/svg+xml',
  441. '.ico': 'image/x-icon',
  442. };
  443. try {
  444. const content = fs.readFileSync(filePath);
  445. res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'application/octet-stream' });
  446. res.end(content);
  447. } catch {
  448. res.writeHead(404, { 'Content-Type': 'text/plain' });
  449. res.end('Not found');
  450. }
  451. }
  452. }