Files
brain/tools/brain-dashboard-server.mjs

76 lines
2.9 KiB
JavaScript

// Static file server for the Brain Dashboard. Serves the repo root over
// localhost so dashboard.html can fetch() episodes-*.jsonl (file:// cannot).
// Run: node tools/brain-dashboard-server.mjs (npm run brain:dashboard)
import { createServer as httpCreateServer } from 'node:http';
import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
import { join, resolve, extname, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..');
const PORT = Number(process.env.BRAIN_DASHBOARD_PORT) || 7700;
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.jsonl': 'application/x-ndjson; charset=utf-8',
'.svg': 'image/svg+xml',
};
export function contentType(ext) {
return MIME[ext] || 'application/octet-stream';
}
export function listEpisodeFiles(root) {
const dir = join(root, 'docs', 'observer');
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter((f) => /^episodes-\d{4}-\d{2}\.jsonl$/.test(f))
.sort();
}
// Resolve a URL path to an absolute path inside root; null if it escapes root.
export function resolveStaticPath(urlPath, root) {
const clean = decodeURIComponent(urlPath.split('?')[0]).replace(/^\/+/, '');
// Use resolve for the traversal check (canonicalizes both sides consistently)
const normRoot = resolve(root);
const abs = resolve(normRoot, clean);
if (abs !== normRoot && !abs.startsWith(normRoot + sep)) return null;
// Return join-based path so callers get root-relative path with root's own separators
return join(root, clean);
}
export function createServer(root = REPO_ROOT) {
return httpCreateServer((req, res) => {
const url = req.url || '/';
if (url.split('?')[0] === '/api/episodes') {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(listEpisodeFiles(root)));
return;
}
let path = url.split('?')[0];
if (path === '/') {
// Redirect (not rewrite) so the browser's base URL becomes /docs/observer/,
// which makes relative <script src="dashboard.js"> and ../automation-graph-data.js resolve correctly.
res.writeHead(302, { Location: '/docs/observer/dashboard.html' });
res.end();
return;
}
const abs = resolveStaticPath(path, root);
if (!abs || !existsSync(abs) || !statSync(abs).isFile()) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('404');
return;
}
res.writeHead(200, { 'Content-Type': contentType(extname(abs)) });
res.end(readFileSync(abs));
});
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
createServer().listen(PORT, '127.0.0.1', () => {
console.log(`Brain Dashboard: http://localhost:${PORT}/ (Ctrl+C to stop)`);
});
}