blueprint / server.js
Jason Morrissette
Add logos: favicon, sidebar theme-aware, planlogo on gate page
62d6cae
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const express = require('express');
const { createServer } = require('http');
const { WebSocketServer } = require('ws');
const { join } = require('path');
const logger = require('./logger');
const sharedState = require('./shared-state');
const db = require('./db');
const safe = require('./safe-exec');
const config = require('./config');
const sessionUtils = require('./session-utils');
const { fireEvent } = require('./webhooks');
const createKeepalive = require('./keepalive');
const createTmuxLifecycle = require('./tmux-lifecycle');
const createSessionResolver = require('./session-resolver');
const createWatchers = require('./watchers');
const createWsTerminal = require('./ws-terminal');
const registerCoreRoutes = require('./routes');
const { handleVoiceConnection } = require('./voice');
// ── Configuration ───────────────────────────────────────────────────────────
const PORT = parseInt(process.env.PORT, 10) || 3000;
const CLAUDE_HOME = safe.CLAUDE_HOME;
const WORKSPACE = safe.WORKSPACE;
const MAX_TMUX_SESSIONS = parseInt(process.env.MAX_TMUX_SESSIONS || '5', 10);
const TMUX_CLEANUP_DELAY = parseInt(process.env.TMUX_CLEANUP_MINUTES || '30', 10) * 60 * 1000;
// ── Global error handlers ───────────────────────────────────────────────────
process.on('uncaughtException', (err) => {
logger.error('Uncaught exception β€” exiting', {
module: 'server',
err: err.message,
stack: err.stack ? err.stack.substring(0, 500) : undefined,
});
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
logger.error('Unhandled rejection', {
module: 'server',
err: reason instanceof Error ? reason.message : String(reason),
});
});
// ── Construct modules with explicit deps ────────────────────────────────────
const keepalive = createKeepalive({ safe, config, logger });
const tmux = createTmuxLifecycle({ safe, MAX_TMUX_SESSIONS, TMUX_CLEANUP_DELAY, logger });
const resolver = createSessionResolver({
db,
safe,
config,
tmuxName: tmux.tmuxName,
tmuxExists: tmux.tmuxExists,
sleep: tmux.sleep,
logger,
});
const watchers = createWatchers({
db,
safe,
config,
sessionUtils,
sessionWsClients: sharedState.sessionWsClients,
tmuxName: tmux.tmuxName,
tmuxExists: tmux.tmuxExists,
CLAUDE_HOME,
logger,
});
const terminal = createWsTerminal({
safe,
keepalive,
logger,
config,
sessionWsClients: sharedState.sessionWsClients,
getBrowserCount: sharedState.getBrowserCount,
incrementBrowserCount: sharedState.incrementBrowserCount,
decrementBrowserCount: sharedState.decrementBrowserCount,
tmuxExists: tmux.tmuxExists,
cancelTmuxCleanup: tmux.cancelTmuxCleanup,
scheduleTmuxCleanup: tmux.scheduleTmuxCleanup,
startJsonlWatcher: watchers.startJsonlWatcher,
stopJsonlWatcher: watchers.stopJsonlWatcher,
});
// Smart compaction removed β€” no kill callback needed
// ── Express setup ───────────────────────────────────────────────────────────
const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ noServer: true });
const voiceWss = new WebSocketServer({ noServer: true });
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// ── Auth gate β€” auto-detects public HF Spaces + password mode ─────────────
let authMode = 'open'; // 'template' | 'password' | 'open'
const sessionTokens = new Set();
async function detectAuthMode() {
const spaceId = process.env.SPACE_ID;
if (spaceId) {
try {
const res = await fetch(`https://huggingface.co/api/spaces/${spaceId}`);
const data = await res.json();
if (!data.private) { authMode = 'template'; return; }
} catch {
authMode = 'template'; return; // fail safe: assume public
}
}
if (process.env.BLUEPRINT_USER && process.env.BLUEPRINT_PASS) {
authMode = 'password';
} else {
authMode = 'open';
}
}
function parseCookie(req, name) {
const cookie = req.headers.cookie || '';
const match = cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
return match ? match[1] : null;
}
function serveGatePage(res) {
const html = fs.readFileSync(join(__dirname, 'public', 'gate.html'), 'utf-8');
res.type('html').send(html.replace(
'// __GATE_MODE_INJECT__',
`const __GATE_MODE__ = '${authMode}';`
));
}
// Login endpoint for password mode
app.post('/api/gate/login', (req, res) => {
if (authMode !== 'password') return res.status(404).json({ error: 'not found' });
const { username, password } = req.body;
if (username === process.env.BLUEPRINT_USER && password === process.env.BLUEPRINT_PASS) {
const token = crypto.randomBytes(32).toString('hex');
sessionTokens.add(token);
res.cookie('bp_session', token, { httpOnly: true, sameSite: 'lax' });
res.json({ success: true });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
app.use((req, res, next) => {
if (authMode === 'open') return next();
// Allow health checks
if (req.path === '/api/health' || req.path === '/health') return next();
// Allow gate assets
if (['/blueprint-preview.png', '/planlogo.png', '/favicon.ico'].includes(req.path)) return next();
// Password mode: check session cookie
if (authMode === 'password') {
const token = parseCookie(req, 'bp_session');
if (token && sessionTokens.has(token)) return next();
}
// Serve gate page
serveGatePage(res);
});
app.use(express.static(join(__dirname, 'public')));
app.use('/lib/xterm', express.static(join(__dirname, 'node_modules/@xterm/xterm')));
app.use('/lib/xterm-fit', express.static(join(__dirname, 'node_modules/@xterm/addon-fit')));
app.use(
'/lib/xterm-web-links',
express.static(join(__dirname, 'node_modules/@xterm/addon-web-links')),
);
app.use('/lib/jqueryfiletree', express.static(join(__dirname, 'node_modules/jqueryfiletree/dist')));
app.use('/lib/jquery', express.static(join(__dirname, 'node_modules/jquery/dist')));
// ── Route registration ──────────────────────────────────────────────────────
const { checkAuthStatus } = registerCoreRoutes(app, {
db,
safe,
config,
sessionUtils,
keepalive,
fireEvent,
logger,
tmuxName: tmux.tmuxName,
tmuxExists: tmux.tmuxExists,
enforceTmuxLimit: tmux.enforceTmuxLimit,
resolveSessionId: resolver.resolveSessionId,
getBrowserCount: sharedState.getBrowserCount,
CLAUDE_HOME,
WORKSPACE,
ensureSettings: watchers.ensureSettings,
sleep: tmux.sleep,
});
// ── WebSocket upgrade handler ───────────────────────────────────────────────
function handleUpgrade(req, socket, head) {
if (authMode === 'template') { socket.destroy(); return; }
if (authMode === 'password') {
const cookie = req.headers.cookie || '';
const match = cookie.match(/bp_session=([a-f0-9]+)/);
if (!match || !sessionTokens.has(match[1])) { socket.destroy(); return; }
}
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname === '/ws/voice') {
voiceWss.handleUpgrade(req, socket, head, (ws) => {
handleVoiceConnection(ws);
});
return;
}
const match = url.pathname.match(/^\/ws\/(.+)$/);
if (!match) {
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => terminal.handleTerminalConnection(ws, match[1]));
}
server.on('upgrade', handleUpgrade);
// ── Exports for testing ─────────────────────────────────────────────────────
module.exports = {
parseSessionFile: sessionUtils.parseSessionFile,
checkAuthStatus,
tmuxName: tmux.tmuxName,
tmuxExists: tmux.tmuxExists,
sleep: tmux.sleep,
};
// ── Startup sequence ────────────────────────────────────────────────────────
if (require.main === module) {
(async () => {
try {
await config.init();
await detectAuthMode();
logger.info('Auth mode detected', { module: 'server', authMode });
// Re-check auth mode every 5 minutes (handles Space visibility changes)
setInterval(detectAuthMode, 5 * 60 * 1000).unref();
await watchers.ensureSettings();
await tmux.cleanOrphanedTmuxSessions();
// Bridge file cleanup removed β€” messaging replaced by tmux (#51)
resolver.resolveStaleNewSessions().catch((err) =>
logger.error('Startup stale-session resolution error', {
module: 'server',
err: err.message,
}),
);
server.listen(PORT, '0.0.0.0', () => {
logger.info('Blueprint running', { module: 'server', port: PORT });
keepalive.start();
watchers.startSettingsWatcher();
watchers.registerMcpServer().catch((err) =>
logger.error('Post-startup MCP registration failed', {
module: 'server',
err: err.message,
}),
);
watchers.trustProjectDirs().catch((err) =>
logger.error('Post-startup trust project dirs failed', {
module: 'server',
err: err.message,
}),
);
});
} catch (err) {
logger.error('Fatal startup error', {
module: 'server',
err: err.message,
stack: err.stack ? err.stack.substring(0, 500) : undefined,
});
process.exit(1);
}
})();
}