Files
codegraph/src/ui-server/static.ts
T
Colby McHenryandClaude Opus 5 0196c2e53a feat(ui): serve the viewer from codegraph ui, loopback-only and read-only (CG-41)
Adds the `codegraph ui [path]` command (alias `web`) and `src/ui-server/`, a
`node:http` server with no framework and no new dependency.

The command reads an index that already exists — it never creates one, so a
missing index prints the same friendly guidance the MCP tools give instead of
a stack trace, and a sensitive system directory is refused up front.

Security is the substance here, not the routing. The server binds 127.0.0.1
only, answers GET and HEAD only, and sends no CORS headers ever. The realistic
attack on a process that serves your source code from a local port is DNS
rebinding, so every request must carry a loopback `Host` (on our port) and, if
it carries an `Origin` at all, a loopback one — anything else is 403 before
the filesystem is touched. Every path resolves through the engine's existing
`validatePathWithinRoot` chokepoint, which already handles `../` traversal and
in-tree symlinks pointing out of the root (#527); `..` segments are refused
outright so a traversal attempt gets a 404 rather than the SPA shell.

`PathRefusalError` moves from `mcp/tools.ts` into the dependency-free
`errors.ts` (re-exported from its old home, so class identity and every
`instanceof` check are unchanged) — that is what lets a non-MCP read sink
enforce the same refusal without importing the MCP tool graph.

Assets come from `dist/viewer/` resolved relative to `__dirname`, the way
`db/index.ts` finds `schema.sql`. Hashed assets are cached immutably,
`index.html` never. Port 4747, or the next free one — an explicit `--port`
stays explicit rather than silently moving. `--no-open` skips the browser, and
`CODEGRAPH_BROWSER` picks one (or `none` to suppress it), which is also what
makes "did it open a browser" testable end to end.

`resolveProjectFile` and the `/api/` handler seam are the boundary CG-42's
JSON API plugs into; `/api/*` 404s as JSON so a typo'd endpoint never returns
the app shell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:17:09 -05:00

139 lines
4.7 KiB
TypeScript

/**
* Static file serving for the viewer's own bundle (`dist/viewer/`).
*
* Deliberately small: a MIME table, a stream, and the SPA fallback. Everything
* that decides WHETHER a path may be read lives in `security.ts`.
*/
import * as fs from 'fs';
import type { ServerResponse } from 'http';
import * as path from 'path';
/**
* Content types for everything the Vite build emits, plus the handful of things
* a future viewer asset might be. Unknown extensions fall back to
* `application/octet-stream`, which — with `X-Content-Type-Options: nosniff` —
* a browser will download rather than execute.
*/
const CONTENT_TYPES: Readonly<Record<string, string>> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.otf': 'font/otf',
'.wasm': 'application/wasm',
};
/** The content type to send for a file, by extension. */
export function contentTypeFor(filePath: string): string {
return CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream';
}
/**
* Cache policy.
*
* Vite content-hashes everything under `assets/`, so those are immutable for a
* year — a reload of the viewer refetches nothing, and an upgraded CodeGraph
* changes the hash and therefore the URL. `index.html` names those hashes, so
* it must never be cached.
*/
export function cacheControlFor(relativePath: string): string {
const normalized = relativePath.split(path.sep).join('/');
return normalized.startsWith('assets/')
? 'public, max-age=31536000, immutable'
: 'no-store';
}
/**
* Stream a file as the response body.
*
* `HEAD` gets identical headers and no body — it is a GET whose body the client
* asked us to skip, which keeps it read-only by construction.
*/
export function sendFile(
res: ServerResponse,
absolutePath: string,
options: { rootDir: string; method: string; extraHeaders?: Record<string, string> }
): void {
let stats: fs.Stats;
try {
stats = fs.statSync(absolutePath);
} catch {
sendText(res, 404, 'Not found', options.method);
return;
}
const relative = path.relative(options.rootDir, absolutePath);
res.writeHead(200, {
'Content-Type': contentTypeFor(absolutePath),
'Content-Length': String(stats.size),
'Cache-Control': cacheControlFor(relative),
'Last-Modified': stats.mtime.toUTCString(),
...options.extraHeaders,
});
if (options.method === 'HEAD') {
res.end();
return;
}
const stream = fs.createReadStream(absolutePath);
stream.on('error', () => {
// Headers are already out, so there is no status left to change: drop the
// connection so the client sees a truncated body rather than a silent lie.
res.destroy();
});
res.on('close', () => stream.destroy());
stream.pipe(res);
}
/** Send a plain-text status response (the error path for a browser or curl). */
export function sendText(res: ServerResponse, status: number, message: string, method: string): void {
const body = Buffer.from(message.endsWith('\n') ? message : `${message}\n`, 'utf-8');
res.writeHead(status, {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': String(body.byteLength),
'Cache-Control': 'no-store',
});
res.end(method === 'HEAD' ? undefined : body);
}
/** Send a JSON response. Used for `/api/*`, which must never get HTML back. */
export function sendJson(res: ServerResponse, status: number, payload: unknown, method: string): void {
const body = Buffer.from(JSON.stringify(payload), 'utf-8');
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': String(body.byteLength),
'Cache-Control': 'no-store',
});
res.end(method === 'HEAD' ? undefined : body);
}
/**
* Whether a request path should fall back to `index.html` when no file matches.
*
* The viewer is hash-routed (`/#/s/<id>`), so in practice only `/` is ever
* requested — but a bookmarked or hand-typed `/anything` should still open the
* app rather than a 404 page. A path that names a FILE (has an extension) never
* falls back: answering `/assets/index-abc123.js` with HTML would hand the
* browser a script that is not a script, and hide a genuinely missing asset
* behind a page that looks like it loaded.
*/
export function shouldFallBackToIndex(pathname: string): boolean {
return path.extname(pathname) === '';
}