The CodeCohesion API is a lightweight TypeScript/Express server that reads analysis JSON files from the processor and exposes them via RESTful HTTP endpoints.
Core Responsibilities:
- Load and parse JSON files from
viewer/public/data/ - Map repository URLs to internal file identifiers
- Filter and aggregate data based on query parameters
- Run on-demand analysis via processor-as-library integration
- Return JSON responses following REST conventions
- Handle errors gracefully with appropriate HTTP status codes
- Serve OpenAPI 3.1 spec and Swagger UI
Non-Responsibilities:
- Storing data in a database (reads JSON files directly)
- Authentication or rate limiting (trust-based)
Why: Already used in processor and viewer. Strong typing prevents errors. Familiar to team.
Alternatives Considered:
- Python + FastAPI: Adds a new language to the project
- Go: Performant but introduces new tooling
- Decision: Stick with TypeScript for consistency
Why: Already required for processor. Mature ecosystem. Works on Railway.
Why:
- Minimal and well-understood
- Battle-tested (used by millions)
- Simple routing and middleware system
- No magic, no heavy abstractions
Alternatives Considered:
- Fastify: Faster but more opinionated
- Koa: Modern but less ecosystem support
- NestJS: Heavy, over-engineered for this use case
- Decision: Express provides best balance of simplicity and functionality
Why: Need to allow viewer (GitHub Pages) and local dev servers to call API.
Why: Processor already defines FileNode, RepositorySnapshot, TimelineData types. Reuse them to maintain consistency.
Why: Simple, no bundler needed for server-side code. Fast compilation.
Why: Railway/Heroku run node dist/server.js directly. No PM2 or Forever needed initially.
Why: Railway captures stdout/stderr. No need for Winston or Bunyan yet.
Future: Add structured logging if debugging becomes difficult.
codecohesion/
├── packages/
│ ├── shared-types/ # Source of truth for shared TypeScript interfaces
│ ├── cli/ # Unified CLI (analyze, view, serve, context, impact, risk, who)
│ └── mcp/ # MCP server (stdio transport)
│
├── processor/ # Git analysis engine (importable as library)
│ └── src/
│ ├── index.ts # Public API exports
│ ├── structure-analyzer.ts # Tree-sitter AST parsing
│ ├── complexity-analyzer.ts # Cyclomatic/cognitive complexity
│ └── coupling-analyzer.ts # Temporal co-change analysis
│
├── viewer/ # 3D visualization (Three.js)
│ └── public/data/ # Analysis JSON files (API reads/writes here)
│
└── api/ # REST API server
├── src/
│ ├── server.ts # Express app setup, CORS, error handling
│ ├── routes.ts # 19 endpoint definitions + HATEOAS links
│ ├── data-loader.ts # File I/O with path traversal protection + LRU cache
│ ├── query-service.ts # Business logic (transforms, aggregation, BFS)
│ ├── process-service.ts # Async job orchestrator with SSE streaming
│ ├── openapi-spec.ts # OpenAPI 3.1 specification object
│ ├── lru-cache.ts # Generic LRU cache implementation
│ └── types.ts # API-specific response types
│
├── src/__tests__/ # Vitest tests (supertest + mocked fs)
├── test/data/ # Test fixtures
├── package.json
└── tsconfig.json
1. Client Request
↓
GET /api/contributors?url=https://github.com/facebook/react&days=30
2. Express Routing (routes.ts)
↓
Match route → Extract query params → Call QueryService
3. Query Service (query-service.ts)
↓
- Find repo by URL → Get repo ID
- Calculate date range (30 days ago)
- Load JSON file via DataLoader
4. Data Loader (data-loader.ts)
↓
- Read file from api/data/react-timeline-full.json
- Parse JSON → Return RepositorySnapshot | TimelineData
5. Query Service (continued)
↓
- Traverse tree, collect FileNode data
- Filter by date range
- Aggregate contributors (Map<email, filesChanged>)
- Return structured response
6. Express Response
↓
res.json({
repository: { id: "react", url: "..." },
period: { since: "2024-10-01", days: 30 },
contributors: [...],
total: 15
})
7. Client Receives JSON
viewer/public/data/
├── repos.json # Repository index (updated by POST /api/process)
├── react-timeline-full.json # Timeline V2 format
├── react-timeline-full-structure.json # Structure analysis
├── react-timeline-full-complexity.json # Complexity analysis
├── react-timeline-full-coupling.json # Coupling analysis
├── gource-data.json # Static snapshot
└── ...
API reads from and writes to viewer/public/data/ (shared with viewer)
Responsibilities:
- Initialize Express app
- Configure CORS (allow GitHub Pages, localhost)
- Mount routes at
/api - Handle 404 and 500 errors
- Start HTTP server on
PORT(default 3001, Railway sets dynamically)
Implementation:
import express from 'express';
import cors from 'cors';
import { createRoutes } from './routes';
const app = express();
const PORT = process.env.PORT || 3001;
// CORS - allow viewer and local dev
app.use(cors({
origin: [
'https://thepaulrayner.com',
'http://localhost:3000',
'http://localhost:3001',
'http://localhost:3002'
]
}));
app.use(express.json());
// API routes
app.use('/api', createRoutes());
// Root endpoint
app.get('/', (req, res) => {
res.json({
service: 'CodeCohesion API',
version: '1.0.0',
docs: 'https://github.com/paulrayner/codecohesion/tree/main/docs/api'
});
});
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
// 500 handler
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error('Error:', err);
res.status(500).json({ error: 'Internal server error' });
});
app.listen(PORT, () => {
console.log(`CodeCohesion API running on port ${PORT}`);
});Responsibilities:
- Define URL patterns and HTTP methods
- Extract request parameters (path params, query strings)
- Call QueryService for business logic
- Return JSON responses or errors
- Handle validation errors (missing params, invalid dates)
Implementation Pattern:
import { Router } from 'express';
import { DataLoader } from './data-loader';
import { QueryService } from './query-service';
export function createRoutes(): Router {
const router = Router();
const dataLoader = new DataLoader();
const queryService = new QueryService(dataLoader);
// List repos or find by URL
router.get('/repos', async (req, res) => {
try {
const { url } = req.query;
if (url) {
const repo = await queryService.findRepoByUrl(url as string);
if (!repo) {
return res.status(404).json({ error: 'Repository not found' });
}
return res.json(repo);
}
const repos = await dataLoader.listRepos();
res.json({ repos });
} catch (error) {
res.status(500).json({ error: 'Failed to list repositories' });
}
});
// Get repository stats
router.get('/repos/:repoId/stats', async (req, res) => {
try {
const { repoId } = req.params;
const stats = await queryService.getStats(repoId);
res.json(stats);
} catch (error) {
res.status(404).json({ error: 'Repository not found' });
}
});
// Get contributors (with date filtering)
router.get('/repos/:repoId/contributors', async (req, res) => {
try {
const { repoId } = req.params;
const { since, until } = req.query;
const contributors = await queryService.getContributors(
repoId,
since as string,
until as string
);
res.json(contributors);
} catch (error) {
res.status(404).json({ error: 'Repository not found' });
}
});
// Convenience endpoint: query by URL directly
router.get('/contributors', async (req, res) => {
try {
const { url, days, since, until } = req.query;
if (!url) {
return res.status(400).json({ error: 'url parameter required' });
}
const repo = await queryService.findRepoByUrl(url as string);
if (!repo) {
return res.status(404).json({ error: 'Repository not found' });
}
// Calculate date range from 'days' parameter
let sinceDate = since as string;
if (days) {
const daysAgo = new Date();
daysAgo.setDate(daysAgo.getDate() - parseInt(days as string));
sinceDate = daysAgo.toISOString().split('T')[0];
}
const contributors = await queryService.getContributors(
repo.id,
sinceDate,
until as string
);
res.json({
...contributors,
repository: { ...contributors.repository, url },
period: { ...contributors.period, days: days ? parseInt(days as string) : undefined }
});
} catch (error) {
res.status(500).json({ error: 'Failed to fetch contributors' });
}
});
// Additional endpoints: files, hotspots, etc.
// ...
return router;
}Responsibilities:
- List JSON files in
api/data/ - Read and parse JSON files
- Map repository IDs to file names
- Handle file not found errors
- Cache file contents (future optimization)
Implementation:
import fs from 'fs/promises';
import path from 'path';
import { RepositorySnapshot, TimelineData } from '../../processor/src/types';
export class DataLoader {
private dataDir: string;
constructor() {
// In production on Railway, use relative path from dist/
// In development, use relative path from src/
this.dataDir = path.join(__dirname, '../data');
}
async listRepos(): Promise<Array<{id: string, name: string, format?: string}>> {
const files = await fs.readdir(this.dataDir);
const jsonFiles = files.filter(f => f.endsWith('.json'));
const repos = await Promise.all(
jsonFiles.map(async (file) => {
const data = await this.loadRepoFile(file);
return {
id: this.fileToRepoId(file),
name: file.replace('.json', ''),
format: 'format' in data ? data.format : 'static'
};
})
);
return repos;
}
async loadRepo(repoId: string): Promise<RepositorySnapshot | TimelineData> {
const file = await this.findFileByRepoId(repoId);
if (!file) {
throw new Error(`Repository not found: ${repoId}`);
}
return this.loadRepoFile(file);
}
private async loadRepoFile(filename: string): Promise<RepositorySnapshot | TimelineData> {
const content = await fs.readFile(
path.join(this.dataDir, filename),
'utf-8'
);
return JSON.parse(content);
}
private fileToRepoId(filename: string): string {
// Simple normalization: lowercase, remove .json
// Example: "react-timeline-full.json" → "react-timeline-full"
return filename.replace('.json', '').toLowerCase();
}
private async findFileByRepoId(repoId: string): Promise<string | null> {
const files = await fs.readdir(this.dataDir);
return files.find(f => this.fileToRepoId(f) === repoId) || null;
}
async findRepoByUrl(url: string): Promise<{id: string, name: string} | null> {
// Extract repo name from URL
// "https://github.com/facebook/react" → "react"
const match = url.match(/\/([^\/]+?)(?:\.git)?$/);
if (!match) return null;
const repoName = match[1].toLowerCase();
const repos = await this.listRepos();
// Find repo whose ID contains the repo name
return repos.find(r => r.id.includes(repoName)) || null;
}
}Responsibilities:
- Implement query logic (contributors, files, hotspots)
- Traverse tree structures (directories → files)
- Filter by date ranges, paths, metrics
- Aggregate data (contributor maps, top N files)
- Format responses for API consumers
Implementation:
import { DataLoader } from './data-loader';
import { FileNode, TreeNode, RepositorySnapshot, TimelineData } from '../../processor/src/types';
interface ContributorInfo {
email: string;
filesChanged: number;
lastModified: string;
}
export class QueryService {
constructor(private dataLoader: DataLoader) {}
async findRepoByUrl(url: string) {
return this.dataLoader.findRepoByUrl(url);
}
async getStats(repoId: string) {
const data = await this.dataLoader.loadRepo(repoId);
const snapshot = this.extractSnapshot(data);
return {
repository: { id: repoId, path: snapshot.repositoryPath },
analyzedAt: snapshot.timestamp,
commit: snapshot.commit,
stats: snapshot.stats
};
}
async getContributors(repoId: string, since?: string, until?: string) {
const data = await this.dataLoader.loadRepo(repoId);
const snapshot = this.extractSnapshot(data);
const contributorMap = new Map<string, ContributorInfo>();
this.traverseTree(snapshot.tree, (node) => {
if (node.type === 'file' && node.lastAuthor) {
// Filter by date range
if (this.isWithinDateRange(node.lastModified, since, until)) {
const existing = contributorMap.get(node.lastAuthor) || {
email: node.lastAuthor,
filesChanged: 0,
lastModified: node.lastModified || ''
};
existing.filesChanged++;
if (node.lastModified && node.lastModified > existing.lastModified) {
existing.lastModified = node.lastModified;
}
contributorMap.set(node.lastAuthor, existing);
}
}
});
return {
repository: { id: repoId },
period: { since, until },
contributors: Array.from(contributorMap.values())
.sort((a, b) => b.filesChanged - a.filesChanged),
total: contributorMap.size
};
}
async getFiles(repoId: string, pathFilter?: string, metric?: string) {
const data = await this.dataLoader.loadRepo(repoId);
const snapshot = this.extractSnapshot(data);
const files: FileNode[] = [];
this.traverseTree(snapshot.tree, (node) => {
if (node.type === 'file') {
if (pathFilter && !node.path.startsWith(pathFilter)) return;
files.push(node);
}
});
// Sort by metric if provided
if (metric === 'churn') {
files.sort((a, b) => (b.commitCount || 0) - (a.commitCount || 0));
} else if (metric === 'contributors') {
files.sort((a, b) => (b.contributorCount || 0) - (a.contributorCount || 0));
} else if (metric === 'loc') {
files.sort((a, b) => b.loc - a.loc);
}
return { files, total: files.length };
}
async getHotspots(repoId: string, limit: number = 20) {
const files = await this.getFiles(repoId);
const topChurn = files.files
.filter(f => f.commitCount)
.sort((a, b) => (b.commitCount || 0) - (a.commitCount || 0))
.slice(0, limit);
const topContributors = files.files
.filter(f => f.contributorCount)
.sort((a, b) => (b.contributorCount || 0) - (a.contributorCount || 0))
.slice(0, limit);
return {
topChurn,
topContributors
};
}
// Helper: Extract snapshot from Timeline or static format
private extractSnapshot(data: RepositorySnapshot | TimelineData): RepositorySnapshot {
return 'headSnapshot' in data ? data.headSnapshot : data;
}
// Helper: Traverse tree recursively
private traverseTree(node: TreeNode, callback: (node: TreeNode) => void) {
callback(node);
if (node.type === 'directory') {
node.children.forEach(child => this.traverseTree(child, callback));
}
}
// Helper: Check if date is within range
private isWithinDateRange(dateStr: string | null, since?: string, until?: string): boolean {
if (!dateStr) return false;
const date = new Date(dateStr);
if (since && date < new Date(since)) return false;
if (until && date > new Date(until)) return false;
return true;
}
}Developer Machine:
├── processor/output/ # Analysis files generated here
├── api/
│ ├── data/ # Copy JSON files here manually
│ └── npm run dev # ts-node src/server.ts (port 3001)
└── viewer/
└── npm run dev # Vite dev server (port 3000)
Workflow:
1. Analyze repo: cd processor && npm run dev -- /path/to/repo
2. Copy JSON: cp processor/output/repo.json api/data/
3. Start API: cd api && npm run dev
4. Test: curl http://localhost:3001/api/repos
Railway Deployment:
├── Git Push → Railway Webhook
├── Railway builds: npm run build (TypeScript → JavaScript)
├── Railway runs: node dist/server.js (Procfile)
├── Environment variables: PORT (set by Railway)
├── Data files: api/data/ (committed to git or Railway volume)
└── Public URL: https://codecohesion-api.railway.app
Procfile:
web: node dist/server.js
package.json scripts:
{
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "ts-node src/server.ts"
}
}tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}Users know GitHub URLs (https://github.com/facebook/react), but files are named arbitrarily (react-timeline-full.json).
// URL → Repo Name
"https://github.com/facebook/react" → "react"
// Find file containing "react"
Files: ["react-timeline-full.json", "gource-data.json"]
Match: "react-timeline-full.json" (contains "react")
// Return repo ID
id: "react-timeline-full"Create processor/output/repos.json:
{
"repos": [
{
"id": "react-timeline-full",
"url": "https://github.com/facebook/react",
"file": "react-timeline-full.json",
"analyzedAt": "2024-10-31T12:00:00Z"
}
]
}Processor updates this file on each analysis run. API reads it for URL lookups.
- 200 OK - Successful request
- 400 Bad Request - Missing or invalid parameters
- 404 Not Found - Repository or resource not found
- 500 Internal Server Error - Unexpected error (file read failure, JSON parse error)
{
"error": "Repository not found",
"details": "No repository matches URL: https://github.com/foo/bar"
}Development: Log everything to console
Production:
- Log errors with timestamps
- Log slow queries (>1s)
- Future: Structured logging (JSON) for Railway log aggregation
- LRU cache (size 20) for parsed JSON data — avoids re-reading files on repeated requests
- Single-threaded Node.js with async I/O
- Path traversal protection on all file reads
Typical Performance:
- Cache hit: <5ms
- Cache miss (file read + JSON parse): 50-200ms for typical files (1-10 MB)
- Query processing: 5-20ms
1. Database Storage (PostgreSQL)
- Store parsed data in normalized tables
- Fast indexed queries (contributors, files)
- No JSON parsing overhead
2. Pre-Computed Aggregations
- Store contributor counts at analysis time
- Pre-calculate hotspots and health scores
3. Streaming Responses
- Stream large file lists instead of buffering entire response
- Path traversal protection on all DataLoader file reads (validated with dedicated security tests)
- Input validation on query parameters (limit ranges, mode validation, NaN checks)
- CORS whitelist — only allow specific origins
- No authentication — public read-only API (POST /api/process is the exception)
- API Keys — per-user or per-app tokens
- Rate Limiting — 100 requests/minute per IP
- HTTPS Only — Railway provides this by default
- QueryService logic (date filtering, aggregation)
- DataLoader file operations (mocked file system)
- URL parsing and repo ID matching
- Full request/response cycle
- Error handling (404, 500)
- CORS headers
- Deploy to Railway staging environment
- Test with curl and Postman
- Verify viewer can call API endpoints
app.get('/health', (req, res) => {
res.json({
status: 'ok',
uptime: process.uptime(),
timestamp: new Date().toISOString()
});
});- Request count
- Response times (p50, p95, p99)
- Error rate (5xx responses)
- Memory usage
- File system reads
- Alert if error rate > 5%
- Alert if p95 latency > 1s
- Alert if memory usage > 500 MB
┌──────────────┐
│ Express API │
└──────┬───────┘
│
├─────▶ PostgreSQL (for fast queries, historical comparisons)
│
└─────▶ File System (current — viewer/public/data/*.json)
Decision: The current JSON file approach works well for single-user analysis. Database would add value for multi-user deployments and historical trend tracking.
Architecture Principles:
- Simplicity First — Express + TypeScript, no unnecessary abstractions
- Layered Design — DataLoader (I/O) → QueryService (transforms) → Routes (HTTP)
- Processor as Library — imports RepositoryAnalyzer, StructureAnalyzer, ComplexityAnalyzer directly
- Graceful Degradation — endpoints work with partial data (e.g., health score without complexity)
Key Components:
server.ts— Express app, CORS, error handlingroutes.ts— 19 endpoint definitions with HATEOAS linksdata-loader.ts— File I/O with path traversal protection and LRU cachingquery-service.ts— Business logic: filtering, aggregation, BFS traversal, health scoringprocess-service.ts— Async job orchestrator with SSE progress streamingopenapi-spec.ts— OpenAPI 3.1 spec covering all endpoints
The architecture provides a comprehensive analysis API backed by JSON files, with on-demand processing via processor-as-library integration.