397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
76 lines
2.9 KiB
JavaScript
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)`);
|
|
});
|
|
}
|