Skip to content

Commit f94421f

Browse files
lanmowerclaude
andcommitted
refactor: extract HTTP handler and startup functions from server.js
- lib/http-handler.js: createHttpHandler factory (134L) for all HTTP routing - lib/server-startup.js: createOnServerReady factory (117L) for server ready logic - lib/server-startup2.js: createAutoImport, createDbRecovery, createPluginLoader (84L) - Route registrations use _routes object with lazy getters for circular dep resolution - server.js reduced from ~575L to 337L with all lib files ≤200L Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 544b857 commit f94421f

5 files changed

Lines changed: 389 additions & 461 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
## [Unreleased] - refactor: extract http handler + startup functions
2+
3+
- Extract HTTP request handler to lib/http-handler.js (createHttpHandler factory, 134L)
4+
- Extract startup functions to lib/server-startup.js (createOnServerReady, 117L) and lib/server-startup2.js (createAutoImport, createDbRecovery, createPluginLoader, 84L)
5+
- server.js reduced from ~575L to 337L; all lib files ≤200L
6+
- Route registrations populate _routes object for lazy getter pattern in http-handler
7+
8+
19
## [Unreleased]
210

311
### Refactor

lib/http-handler.js

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import os from 'os';
4+
import crypto from 'crypto';
5+
6+
export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, wss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap, convRoutes, messagesRoutes, sessionsRoutes, scriptsRoutes, runsRoutes, agentRoutes, oauthRoutes, agentActionsRoutes, authConfigRoutes, speechRoutes, utilRoutes, threadRoutes, handleGeminiOAuthCallback, handleCodexOAuthCallback, PORT }) {
7+
return async function httpHandler(req, res) {
8+
res.setHeader('Access-Control-Allow-Origin', '*');
9+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
10+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
11+
if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
12+
if (req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket') return;
13+
14+
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress;
15+
const hits = (rateLimitMap.get(clientIp) || 0) + 1;
16+
rateLimitMap.set(clientIp, hits);
17+
res.setHeader('X-RateLimit-Limit', RATE_LIMIT_MAX);
18+
res.setHeader('X-RateLimit-Remaining', Math.max(0, RATE_LIMIT_MAX - hits));
19+
if (hits > RATE_LIMIT_MAX) { res.writeHead(429, { 'Retry-After': '60' }); res.end('Too Many Requests'); return; }
20+
21+
const _pwd = process.env.PASSWORD;
22+
if (_pwd) {
23+
const _auth = req.headers['authorization'] || '';
24+
let _ok = false;
25+
if (_auth.startsWith('Basic ')) {
26+
try {
27+
const _decoded = Buffer.from(_auth.slice(6), 'base64').toString('utf8');
28+
const _ci = _decoded.indexOf(':');
29+
if (_ci !== -1) { const _p = _decoded.slice(_ci + 1); try { _ok = _p.length === _pwd.length && crypto.timingSafeEqual(Buffer.from(_p), Buffer.from(_pwd)); } catch { _ok = false; } }
30+
} catch (_) {}
31+
}
32+
if (!_ok) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="agentgui"' }); res.end('Unauthorized'); return; }
33+
}
34+
35+
const pathOnly = req.url.split('?')[0];
36+
if (pathOnly.startsWith(BASE_URL + '/api/upload/') || pathOnly.startsWith(BASE_URL + '/files/')) return expressApp(req, res);
37+
38+
if (req.url === '/favicon.ico' || req.url === BASE_URL + '/favicon.ico') {
39+
const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#3b82f6"/><text x="50" y="68" font-size="50" font-family="sans-serif" font-weight="bold" fill="white" text-anchor="middle">G</text></svg>';
40+
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=86400' });
41+
res.end(svg); return;
42+
}
43+
44+
if (req.url === '/') { res.writeHead(302, { Location: BASE_URL + '/' }); res.end(); return; }
45+
46+
let routePath = req.url;
47+
if (req.url.startsWith(BASE_URL + '/')) { routePath = req.url.slice(BASE_URL.length); }
48+
else if (req.url === BASE_URL) { routePath = '/'; }
49+
else if (req.url.startsWith('/api/') || req.url.startsWith('/js/') || req.url.startsWith('/css/') ||
50+
req.url.startsWith('/vendor/') || req.url.startsWith('/sync') || req.url === '/' ||
51+
req.url.startsWith('/conversations/')) { routePath = req.url; }
52+
else { res.writeHead(404); res.end('Not found'); return; }
53+
54+
routePath = routePath || '/';
55+
56+
try {
57+
const pathOnly = routePath.split('?')[0];
58+
59+
if (pathOnly === '/oauth2callback' && req.method === 'GET') { await handleGeminiOAuthCallback(req, res, PORT); return; }
60+
if (pathOnly === '/codex-oauth2callback' && req.method === 'GET') { await handleCodexOAuthCallback(req, res, PORT); return; }
61+
62+
if (pathOnly === '/api/health' && req.method === 'GET') {
63+
let dbStatus = { ok: true };
64+
try { queries._db.prepare('SELECT 1').get(); } catch (e) { dbStatus = { ok: false, error: e.message }; }
65+
const queueSizes = {};
66+
for (const [k, v] of messageQueues) queueSizes[k] = v.length;
67+
sendJSON(req, res, 200, { status: 'ok', version: PKG_VERSION, uptime: process.uptime(), agents: discoveredAgents.length, activeExecutions: activeExecutions.size, wsClients: wss.clients.size, memory: process.memoryUsage(), acp: getACPStatus(), db: dbStatus, queueSizes });
68+
return;
69+
}
70+
71+
const convHandler = convRoutes._match(req.method, pathOnly);
72+
if (convHandler) { await convHandler(req, res); return; }
73+
const messagesHandler = messagesRoutes._match(req.method, pathOnly);
74+
if (messagesHandler) { await messagesHandler(req, res); return; }
75+
const sessionsHandler = sessionsRoutes._match(req.method, pathOnly);
76+
if (sessionsHandler) { await sessionsHandler(req, res); return; }
77+
const scriptsHandler = scriptsRoutes._match(req.method, pathOnly);
78+
if (scriptsHandler) { await scriptsHandler(req, res); return; }
79+
const runsHandler = runsRoutes._match(req.method, pathOnly);
80+
if (runsHandler) { await runsHandler(req, res); return; }
81+
const agentHandler = agentRoutes._match(req.method, pathOnly);
82+
if (agentHandler) { await agentHandler(req, res); return; }
83+
const oauthHandler = oauthRoutes._match(req.method, pathOnly);
84+
if (oauthHandler) { await oauthHandler(req, res); return; }
85+
const agentActionsHandler = agentActionsRoutes._match(req.method, pathOnly);
86+
if (agentActionsHandler) { await agentActionsHandler(req, res); return; }
87+
const authConfigHandler = authConfigRoutes._match(req.method, pathOnly);
88+
if (authConfigHandler) { await authConfigHandler(req, res); return; }
89+
const speechHandler = speechRoutes._match(req.method, pathOnly);
90+
if (speechHandler) { await speechHandler(req, res, pathOnly); return; }
91+
const utilHandler = utilRoutes._match(req.method, pathOnly);
92+
if (utilHandler) { await utilHandler(req, res); return; }
93+
const threadHandler = threadRoutes._match(req.method, pathOnly);
94+
if (threadHandler) { await threadHandler(req, res); return; }
95+
if (routePath.startsWith('/api/image/')) {
96+
const imagePath = routePath.slice('/api/image/'.length);
97+
const decodedPath = decodeURIComponent(imagePath);
98+
const expandedPath = decodedPath.startsWith('~') ? decodedPath.replace('~', os.homedir()) : decodedPath;
99+
const normalizedPath = path.normalize(expandedPath);
100+
const isWindows = os.platform() === 'win32';
101+
const isAbsolute = isWindows ? /^[A-Za-z]:[\\\/]/.test(normalizedPath) : normalizedPath.startsWith('/');
102+
if (!isAbsolute || normalizedPath.includes('..')) { res.writeHead(403); res.end('Forbidden'); return; }
103+
try {
104+
if (!fs.existsSync(normalizedPath)) { res.writeHead(404); res.end('Not found'); return; }
105+
const ext = path.extname(normalizedPath).toLowerCase();
106+
const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml' };
107+
const contentType = mimeTypes[ext] || 'application/octet-stream';
108+
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' });
109+
res.end(fs.readFileSync(normalizedPath));
110+
} catch (err) { sendJSON(req, res, 400, { error: err.message }); }
111+
return;
112+
}
113+
114+
if (pathOnly.match(/^\/conversations\/[^\/]+$/)) { serveFile(path.join(staticDir, 'index.html'), res, req); return; }
115+
116+
let filePath = routePath === '/' ? '/index.html' : routePath;
117+
filePath = path.join(staticDir, filePath);
118+
const normalizedPath = path.normalize(filePath);
119+
if (!normalizedPath.startsWith(staticDir)) { res.writeHead(403); res.end('Forbidden'); return; }
120+
121+
fs.stat(filePath, (err, stats) => {
122+
if (err) { res.writeHead(404); res.end('Not found'); return; }
123+
if (stats.isDirectory()) {
124+
filePath = path.join(filePath, 'index.html');
125+
fs.stat(filePath, (err2) => { if (err2) { res.writeHead(404); res.end('Not found'); return; } serveFile(filePath, res, req); });
126+
} else { serveFile(filePath, res, req); }
127+
});
128+
} catch (e) {
129+
console.error('Server error:', e.message);
130+
sendJSON(req, res, 500, { error: e.message });
131+
}
132+
};
133+
}

lib/server-startup.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { JsonlWatcher } from './jsonl-watcher.js';
2+
3+
export function createOnServerReady({ queries, broadcastSync, warmAssetCache, staticDir, toolManager, discoveredAgents, PORT, BASE_URL, watch, ownedSessionIds, resumeInterruptedStreams, activeExecutions, debugLog, installGMAgentConfigs, startACPTools, getACPStatus, execMachine, toolInstallMachine, getSpeech, ensureModelsDownloaded, performAutoImport, performAgentHealthCheck, pm2Manager, pm2Subscribers, recoverStaleSessions }) {
4+
let jsonlWatcher = null;
5+
6+
function getJsonlWatcher() { return jsonlWatcher; }
7+
8+
function onServerReady() {
9+
toolManager.clearStatusCache();
10+
console.log(`GMGUI running on http://localhost:${PORT}${BASE_URL}/`);
11+
console.log(`Agents: ${discoveredAgents.map(a => a.name).join(', ') || 'none'}`);
12+
console.log(`Hot reload: ${watch ? 'on' : 'off'}`);
13+
14+
const deletedCount = queries.cleanupEmptyConversations();
15+
if (deletedCount > 0) console.log(`Cleaned up ${deletedCount} empty conversation(s) on startup`);
16+
17+
recoverStaleSessions();
18+
warmAssetCache(staticDir);
19+
20+
try { queries.cleanup(); console.log('[cleanup] Initial DB cleanup complete'); } catch (e) { console.error('[cleanup] Error:', e.message); }
21+
setInterval(() => {
22+
try { queries.cleanup(); console.log('[cleanup] Scheduled DB cleanup complete'); } catch (e) { console.error('[cleanup] Error:', e.message); }
23+
}, 6 * 60 * 60 * 1000);
24+
25+
try {
26+
jsonlWatcher = new JsonlWatcher({ broadcastSync, queries, ownedSessionIds });
27+
jsonlWatcher.start();
28+
console.log('[JSONL] Watcher started');
29+
} catch (err) { console.error('[JSONL] Watcher failed to start:', err.message); }
30+
31+
resumeInterruptedStreams().catch(err => console.error('[RESUME] Startup error:', err.message));
32+
33+
setInterval(() => {
34+
try {
35+
const streaming = queries.getStreamingConversations();
36+
let cleared = 0;
37+
for (const c of streaming) { if (!activeExecutions.has(c.id)) { queries.setIsStreaming(c.id, false); cleared++; } }
38+
if (cleared > 0) debugLog(`[HEALTH] Cleared ${cleared} stale streaming flag(s)`);
39+
} catch (e) { debugLog(`[HEALTH] Error: ${e.message}`); }
40+
}, 5 * 60 * 1000);
41+
42+
installGMAgentConfigs().catch(err => console.error('[GM-CONFIG] Startup error:', err.message));
43+
44+
startACPTools().then(() => {
45+
console.log('[ACP] On-demand startup enabled (ACP tools start when first used)');
46+
setTimeout(() => {
47+
const acpStatus = getACPStatus();
48+
for (const s of acpStatus) { if (s.healthy) { const agent = discoveredAgents.find(a => a.id === s.id); if (agent) agent.acpPort = s.port; } }
49+
if (acpStatus.length > 0) console.log(`[ACP] Tools ready: ${acpStatus.filter(s => s.healthy).map(s => s.id + ':' + s.port).join(', ') || 'none healthy yet'}`);
50+
}, 6000);
51+
}).catch(err => console.error('[ACP] Startup error:', err.message));
52+
53+
const toolIds = ['cli-claude', 'cli-opencode', 'cli-gemini', 'cli-kilo', 'cli-codex', 'cli-agent-browser', 'gm-cc', 'gm-oc', 'gm-gc', 'gm-kilo'];
54+
queries.initializeToolInstallations(toolIds.map(id => ({ id })));
55+
console.log('[TOOLS] Starting background provisioning...');
56+
57+
const toolBroadcaster = (evt) => {
58+
broadcastSync(evt);
59+
if (evt.type === 'tool_install_complete' || evt.type === 'tool_update_complete') {
60+
const d = evt.data || {};
61+
queries.updateToolStatus(evt.toolId, { status: 'installed', version: d.version || null, installed_at: Date.now() });
62+
queries.addToolInstallHistory(evt.toolId, evt.type.includes('update') ? 'update' : 'install', 'success', null);
63+
} else if (evt.type === 'tool_install_failed' || evt.type === 'tool_update_failed') {
64+
queries.updateToolStatus(evt.toolId, { status: 'failed', error_message: evt.data?.error });
65+
queries.addToolInstallHistory(evt.toolId, evt.type.includes('update') ? 'update' : 'install', 'failed', evt.data?.error);
66+
} else if (evt.type === 'tool_status_update') {
67+
const d = evt.data || {};
68+
if (d.installed) queries.updateToolStatus(evt.toolId, { status: 'installed', version: d.installedVersion || null, installed_at: Date.now() });
69+
}
70+
};
71+
72+
toolManager.autoProvision(toolBroadcaster)
73+
.catch(err => console.error('[TOOLS] Auto-provision error:', err.message))
74+
.then(() => {
75+
const acpActors = ['opencode', 'kilo', 'codex'];
76+
console.log(`[MACHINES] tool-install: ${toolInstallMachine.getMachineActors().size} actors, acp-server: ${acpActors.length} configured`);
77+
console.log('[TOOLS] Starting periodic update checker...');
78+
toolManager.startPeriodicUpdateCheck(toolBroadcaster);
79+
});
80+
81+
ensureModelsDownloaded().then(async ok => {
82+
if (ok) console.log('[MODELS] Speech models ready');
83+
else console.log('[MODELS] Speech model download failed');
84+
try { const { getVoices } = await getSpeech(); broadcastSync({ type: 'voice_list', voices: getVoices() }); }
85+
catch (err) { debugLog('[VOICE] Failed to broadcast voices: ' + err.message); broadcastSync({ type: 'voice_list', voices: [] }); }
86+
}).catch(async err => {
87+
console.error('[MODELS] Download error:', err.message);
88+
try { const { getVoices } = await getSpeech(); broadcastSync({ type: 'voice_list', voices: getVoices() }); }
89+
catch (err2) { debugLog('[VOICE] Failed to broadcast voices: ' + err2.message); broadcastSync({ type: 'voice_list', voices: [] }); }
90+
});
91+
92+
getSpeech().then(s => s.preloadTTS()).catch(e => debugLog('[TTS] Preload failed: ' + e.message));
93+
performAutoImport();
94+
setInterval(performAutoImport, 30000);
95+
setInterval(performAgentHealthCheck, 30000);
96+
97+
const broadcastPM2 = (update) => {
98+
const msg = JSON.stringify(update);
99+
for (const client of pm2Subscribers) { if (client.readyState === 1) { try { client.send(msg); } catch (_) {} } }
100+
};
101+
102+
const startPM2Monitoring = async () => {
103+
try { await pm2Manager.connect(); await pm2Manager.startMonitoring(broadcastPM2); console.log('[PM2] Monitoring started'); }
104+
catch (err) { console.log('[PM2] Not available:', err.message); broadcastPM2({ type: 'pm2_unavailable', reason: err.message, timestamp: Date.now() }); }
105+
};
106+
107+
setTimeout(startPM2Monitoring, 2000);
108+
setInterval(async () => {
109+
if (!pm2Manager.connected && !pm2Manager.monitoring) {
110+
try { const healed = await pm2Manager.heal(); if (healed.success) await pm2Manager.startMonitoring(broadcastPM2); } catch (_) {}
111+
}
112+
}, 30000);
113+
}
114+
115+
return { onServerReady, getJsonlWatcher };
116+
}

lib/server-startup2.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import os from 'os';
4+
import PluginLoader from './plugin-loader.js';
5+
6+
export function createAutoImport({ queries, broadcastSync }) {
7+
const importMtimeCache = new Map();
8+
9+
function hasIndexFilesChanged() {
10+
const projectsDir = path.join(os.homedir(), '.claude', 'projects');
11+
if (!fs.existsSync(projectsDir)) return false;
12+
let changed = false;
13+
try {
14+
const dirs = fs.readdirSync(projectsDir);
15+
for (const d of dirs) {
16+
const indexPath = path.join(projectsDir, d, 'sessions-index.json');
17+
try {
18+
const stat = fs.statSync(indexPath);
19+
const cached = importMtimeCache.get(indexPath);
20+
if (!cached || cached < stat.mtimeMs) { importMtimeCache.set(indexPath, stat.mtimeMs); changed = true; }
21+
} catch (_) {}
22+
}
23+
} catch (_) {}
24+
return changed;
25+
}
26+
27+
function performAutoImport() {
28+
try {
29+
if (!hasIndexFilesChanged()) return;
30+
const imported = queries.importClaudeCodeConversations();
31+
if (imported.length > 0) {
32+
const importedCount = imported.filter(i => i.status === 'imported').length;
33+
if (importedCount > 0) {
34+
console.log(`[AUTO-IMPORT] Imported ${importedCount} new Claude Code conversations`);
35+
broadcastSync({ type: 'conversations_updated', count: importedCount });
36+
}
37+
}
38+
} catch (err) { console.error('[AUTO-IMPORT] Error:', err.message); }
39+
}
40+
41+
return { performAutoImport };
42+
}
43+
44+
export function createDbRecovery({ queries, debugLog }) {
45+
function performDbRecovery() {
46+
try {
47+
const cleanedUp = queries.cleanupOrphanedSessions(7);
48+
if (cleanedUp > 0) debugLog(`[RECOVERY] Cleaned up ${cleanedUp} orphaned sessions`);
49+
const longRunning = queries.getSessionsProcessingLongerThan(120);
50+
if (longRunning.length > 0) {
51+
for (const session of longRunning) queries.markSessionIncomplete(session.id, 'Timeout: processing exceeded 2 hours');
52+
debugLog(`[RECOVERY] Marked ${longRunning.length} long-running sessions as incomplete`);
53+
}
54+
} catch (err) { console.error('[RECOVERY] Error:', err.message); }
55+
}
56+
57+
return { performDbRecovery };
58+
}
59+
60+
export function createPluginLoader({ pluginsDir, expressApp, BASE_URL }) {
61+
const pluginLoader = new PluginLoader(pluginsDir);
62+
63+
async function loadPluginExtensions() {
64+
try {
65+
await pluginLoader.loadAllPlugins({ router: expressApp, baseUrl: BASE_URL, logger: console, env: process.env });
66+
const names = Array.from(pluginLoader.registry.keys());
67+
if (names.length > 0) {
68+
for (const name of names) {
69+
const state = pluginLoader.get(name);
70+
if (!state || !state.routes) continue;
71+
for (const route of state.routes) {
72+
const fullPath = BASE_URL + route.path;
73+
const method = (route.method || 'GET').toLowerCase();
74+
if (expressApp[method]) expressApp[method](fullPath, route.handler);
75+
}
76+
}
77+
console.log(`[PLUGINS] Loaded extensions: ${names.join(', ')}`);
78+
}
79+
} catch (err) { console.error('[PLUGINS] Extension loading failed (non-fatal):', err.message); }
80+
}
81+
82+
return { loadPluginExtensions };
83+
}

0 commit comments

Comments
 (0)