1
0

server.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  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 config = this.cg.getConfig();
  204. const embeddingStats = this.cg.getEmbeddingStats();
  205. const isEnabled = config.enableEmbeddings === true;
  206. const isInitialized = this.cg.isEmbeddingsInitialized();
  207. const totalVectors = embeddingStats?.totalVectors ?? 0;
  208. const stats = this.cg.getStats();
  209. // Consider ready if we have vectors for at least half the eligible nodes
  210. const eligibleNodes = stats.nodeCount - (stats.nodesByKind.file ?? 0) - (stats.nodesByKind.import ?? 0);
  211. const isReady = isEnabled && totalVectors > 0 && totalVectors >= eligibleNodes * 0.5;
  212. json({ isEnabled, isInitialized, isReady, totalVectors, eligibleNodes });
  213. return;
  214. }
  215. // GET /api/embeddings/generate — SSE stream that enables, initializes, and generates embeddings
  216. if (pathname === '/api/embeddings/generate') {
  217. res.writeHead(200, {
  218. 'Content-Type': 'text/event-stream',
  219. 'Cache-Control': 'no-cache',
  220. 'Connection': 'keep-alive',
  221. });
  222. const send = (event: string, data: unknown) => {
  223. res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
  224. };
  225. try {
  226. // Step 1: Enable embeddings in config
  227. send('status', { phase: 'config', message: 'Enabling embeddings...' });
  228. const config = this.cg.getConfig();
  229. if (!config.enableEmbeddings) {
  230. this.cg.updateConfig({ enableEmbeddings: true });
  231. }
  232. // Step 2: Initialize embedding model (downloads on first use)
  233. send('status', { phase: 'model', message: 'Loading embedding model (first time may download ~30MB)...' });
  234. await this.cg.initializeEmbeddings();
  235. send('status', { phase: 'model', message: 'Embedding model ready' });
  236. // Step 3: Generate embeddings with progress
  237. send('status', { phase: 'embedding', message: 'Generating embeddings...' });
  238. const count = await this.cg.generateEmbeddings((progress) => {
  239. send('progress', {
  240. current: progress.current,
  241. total: progress.total,
  242. nodeName: progress.nodeName,
  243. percent: progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0,
  244. });
  245. });
  246. send('complete', { totalEmbedded: count, message: `Generated ${count} embeddings` });
  247. } catch (err) {
  248. const message = err instanceof Error ? err.message : String(err);
  249. send('error', { message });
  250. }
  251. res.end();
  252. return;
  253. }
  254. // GET /api/search?q=...&kind=...&limit=...
  255. if (pathname === '/api/search') {
  256. const q = query.q || '';
  257. const kind = query.kind as NodeKind | undefined;
  258. const limit = parseInt(query.limit || '30', 10);
  259. if (!q) {
  260. json({ results: [] });
  261. return;
  262. }
  263. const results = this.cg.searchNodes(q, { kinds: kind ? [kind] : undefined, limit });
  264. json({ results });
  265. return;
  266. }
  267. // GET /api/explore?q=...
  268. // Find the best entry point, then return its call graph
  269. if (pathname === '/api/explore') {
  270. const q = query.q || '';
  271. if (!q) {
  272. json({ nodes: [], edges: [], roots: [], entryPoint: null });
  273. return;
  274. }
  275. let entryNodeId: string | null = null;
  276. let usedClaude = false;
  277. const validKinds: NodeKind[] = ['function', 'method', 'class', 'interface', 'component', 'route'];
  278. // Try Claude CLI to find the best entry point
  279. const claudeNames = await this.askClaude(q);
  280. if (claudeNames && claudeNames.length > 0) {
  281. usedClaude = true;
  282. // Find the entry point in the graph
  283. for (const name of claudeNames) {
  284. if (entryNodeId) break;
  285. const results = this.cg.searchNodes(name, { kinds: validKinds, limit: 3 });
  286. for (const r of results) {
  287. if (r.node.name.toLowerCase() === name.toLowerCase() ||
  288. r.node.name.toLowerCase().includes(name.toLowerCase()) ||
  289. name.toLowerCase().includes(r.node.name.toLowerCase())) {
  290. entryNodeId = r.node.id;
  291. break;
  292. }
  293. }
  294. }
  295. }
  296. // Keyword fallback: find best match from query keywords
  297. if (!entryNodeId) {
  298. 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']);
  299. const keywords = q.toLowerCase().split(/\s+/)
  300. .map(w => w.replace(/[^a-z0-9]/g, ''))
  301. .filter(w => w.length >= 2 && !stopWords.has(w));
  302. for (const kw of keywords) {
  303. if (entryNodeId) break;
  304. const results = this.cg.searchNodes(kw, { kinds: validKinds, limit: 5 });
  305. if (results.length > 0) {
  306. entryNodeId = results[0]!.node.id;
  307. }
  308. }
  309. }
  310. if (!entryNodeId) {
  311. json({ nodes: [], edges: [], roots: [], entryPoint: null });
  312. return;
  313. }
  314. // Get the call graph from this entry point (depth 3)
  315. const callGraph = this.cg.getCallGraph(entryNodeId, 3);
  316. const result = serializeSubgraph(callGraph);
  317. json({
  318. nodes: result.nodes,
  319. edges: result.edges,
  320. roots: [entryNodeId],
  321. entryPoint: entryNodeId,
  322. usedClaude,
  323. });
  324. return;
  325. }
  326. // GET /api/overview?limit=...
  327. if (pathname === '/api/overview') {
  328. const limit = parseInt(query.limit || '50', 10);
  329. // Get top-level exported classes, functions, components
  330. const kinds: NodeKind[] = ['class', 'function', 'interface', 'component', 'enum', 'type_alias'];
  331. const nodes: Node[] = [];
  332. for (const kind of kinds) {
  333. const kindNodes = this.cg.getNodesByKind(kind);
  334. for (const n of kindNodes) {
  335. if (n.isExported || n.kind === 'class' || n.kind === 'component') {
  336. nodes.push(n);
  337. }
  338. if (nodes.length >= limit) break;
  339. }
  340. if (nodes.length >= limit) break;
  341. }
  342. json({ nodes });
  343. return;
  344. }
  345. // GET /api/files
  346. if (pathname === '/api/files') {
  347. const files = this.cg.getFiles();
  348. json({ files });
  349. return;
  350. }
  351. // Routes with node ID: /api/node/<id>/...
  352. const nodeMatch = pathname.match(/^\/api\/node\/([^/]+)(\/.*)?$/);
  353. if (nodeMatch) {
  354. const nodeId = decodeURIComponent(nodeMatch[1]!);
  355. const sub = nodeMatch[2] || '';
  356. // GET /api/node/<id>
  357. if (!sub || sub === '/') {
  358. const node = this.cg.getNode(nodeId);
  359. if (!node) {
  360. json({ error: 'Node not found' }, 404);
  361. return;
  362. }
  363. const code = await this.cg.getCode(nodeId);
  364. const ancestors = this.cg.getAncestors(nodeId);
  365. json({ node, code, ancestors });
  366. return;
  367. }
  368. // GET /api/node/<id>/callers?depth=...
  369. if (sub === '/callers') {
  370. const depth = parseInt(query.depth || '1', 10);
  371. const items = this.cg.getCallers(nodeId, depth);
  372. json({ items });
  373. return;
  374. }
  375. // GET /api/node/<id>/callees?depth=...
  376. if (sub === '/callees') {
  377. const depth = parseInt(query.depth || '1', 10);
  378. const items = this.cg.getCallees(nodeId, depth);
  379. json({ items });
  380. return;
  381. }
  382. // GET /api/node/<id>/children
  383. if (sub === '/children') {
  384. const children = this.cg.getChildren(nodeId);
  385. json({ children });
  386. return;
  387. }
  388. // GET /api/node/<id>/impact?depth=...
  389. if (sub === '/impact') {
  390. const depth = parseInt(query.depth || '2', 10);
  391. const subgraph = this.cg.getImpactRadius(nodeId, depth);
  392. json(serializeSubgraph(subgraph));
  393. return;
  394. }
  395. // GET /api/node/<id>/callgraph?depth=...
  396. if (sub === '/callgraph') {
  397. const depth = parseInt(query.depth || '2', 10);
  398. const subgraph = this.cg.getCallGraph(nodeId, depth);
  399. json(serializeSubgraph(subgraph));
  400. return;
  401. }
  402. // GET /api/node/<id>/context
  403. if (sub === '/context') {
  404. const context = this.cg.getContext(nodeId);
  405. json({ context });
  406. return;
  407. }
  408. json({ error: 'Unknown endpoint' }, 404);
  409. return;
  410. }
  411. // GET /api/file-nodes?path=...
  412. if (pathname === '/api/file-nodes') {
  413. const filePath = query.path || '';
  414. if (!filePath) {
  415. json({ error: 'path parameter required' }, 400);
  416. return;
  417. }
  418. const nodes = this.cg.getNodesInFile(filePath);
  419. json({ nodes });
  420. return;
  421. }
  422. json({ error: 'Unknown API endpoint' }, 404);
  423. } catch (err) {
  424. const message = err instanceof Error ? err.message : String(err);
  425. json({ error: message }, 500);
  426. }
  427. }
  428. private serveStatic(pathname: string, res: http.ServerResponse): void {
  429. if (pathname === '/' || pathname === '/index.html') {
  430. pathname = '/index.html';
  431. }
  432. // Resolve from the public directory next to this file
  433. const publicDir = path.join(__dirname, 'public');
  434. const filePath = path.join(publicDir, pathname);
  435. // Security: prevent directory traversal
  436. if (!filePath.startsWith(publicDir)) {
  437. res.writeHead(403);
  438. res.end('Forbidden');
  439. return;
  440. }
  441. const ext = path.extname(filePath).toLowerCase();
  442. const mimeTypes: Record<string, string> = {
  443. '.html': 'text/html',
  444. '.css': 'text/css',
  445. '.js': 'application/javascript',
  446. '.json': 'application/json',
  447. '.png': 'image/png',
  448. '.svg': 'image/svg+xml',
  449. '.ico': 'image/x-icon',
  450. };
  451. try {
  452. const content = fs.readFileSync(filePath);
  453. res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'application/octet-stream' });
  454. res.end(content);
  455. } catch {
  456. res.writeHead(404, { 'Content-Type': 'text/plain' });
  457. res.end('Not found');
  458. }
  459. }
  460. }