refactor: Modularize server into separate concerns

Split monolithic index.ts (~1400 lines) into modular structure:
- config.ts: Server configuration and constants
- db/: Database initialization, migrations, and seeds
- routes/: API handlers by domain (themes, canvas, components, etc.)
- services/: Terminal WebSocket server
- utils/: CORS helpers

Entry point now only coordinates initialization.
This commit is contained in:
2026-02-13 13:01:18 -06:00
parent 9681ce4198
commit 645f51a74e
16 changed files with 1503 additions and 1382 deletions

192
server/services/terminal.ts Normal file
View File

@@ -0,0 +1,192 @@
import { spawn, type IPty } from '@skitee3000/bun-pty'
import { PORT_TERMINAL, WORKING_DIR, SHELL, SHELL_ARGS, DEFAULT_SESSION_ID, MAX_BUFFER_LINES } from '../config'
interface TerminalSession {
id: string
pty: IPty
outputBuffer: string[]
maxBufferSize: number
clients: Set<any>
createdAt: Date
}
// Store active terminal sessions by ID (persistent across reconnections)
const sessions = new Map<string, TerminalSession>()
// Map WebSocket to sessionId
const wsToSession = new Map<any, string>()
function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSession {
let session = sessions.get(sessionId)
if (!session) {
console.log(`[Terminal] Creating new session: ${sessionId}`)
const pty = spawn(SHELL, SHELL_ARGS, {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd: WORKING_DIR
})
session = {
id: sessionId,
pty,
outputBuffer: [],
maxBufferSize: MAX_BUFFER_LINES,
clients: new Set(),
createdAt: new Date()
}
// Capture output to buffer and send to clients
pty.onData((data: string) => {
session!.outputBuffer.push(data)
if (session!.outputBuffer.length > session!.maxBufferSize) {
session!.outputBuffer.shift()
}
for (const ws of session!.clients) {
try {
ws.send(JSON.stringify({ type: 'output', data }))
} catch {
// Client disconnected
}
}
})
// Handle PTY exit
pty.onExit(({ exitCode }) => {
console.log(`[Terminal] Session ${sessionId} exited with code ${exitCode}`)
for (const ws of session!.clients) {
try {
ws.send(JSON.stringify({
type: 'exit',
data: `\r\n\x1b[33mSession ended (code ${exitCode})\x1b[0m\r\n`
}))
} catch { /* ignore */ }
}
sessions.delete(sessionId)
})
sessions.set(sessionId, session)
console.log(`[Terminal] Session ${sessionId} created, PID: ${pty.pid}`)
}
return session
}
export function startTerminalServer() {
const server = Bun.serve({
port: PORT_TERMINAL,
fetch(req, server) {
const url = new URL(req.url)
// Health check with session info
if (url.pathname === '/health') {
const sessionsInfo = Array.from(sessions.entries()).map(([id, s]) => ({
id,
clients: s.clients.size,
pid: s.pty.pid,
bufferSize: s.outputBuffer.length,
createdAt: s.createdAt.toISOString()
}))
return Response.json({
status: 'ok',
sessions: sessionsInfo,
cwd: WORKING_DIR
})
}
// List active sessions
if (url.pathname === '/sessions') {
const list = Array.from(sessions.keys())
return Response.json({ sessions: list })
}
// Check if this is a WebSocket upgrade request
const upgradeHeader = req.headers.get('upgrade')
console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`)
if (upgradeHeader?.toLowerCase() === 'websocket') {
const sessionId = url.searchParams.get('session') || DEFAULT_SESSION_ID
const success = server.upgrade(req, { data: { sessionId } })
console.log(`[Terminal] WebSocket upgrade for session "${sessionId}": ${success ? 'success' : 'failed'}`)
if (success) {
return undefined
}
return new Response('WebSocket upgrade failed', { status: 400 })
}
return new Response(
'Terminal WebSocket Server - Persistent Sessions\n\nEndpoints:\n /health - Server status\n /sessions - List active sessions\n ws://...?session=<id> - Connect to session',
{ status: 200 }
)
},
websocket: {
open(ws) {
const sessionId = (ws.data as any)?.sessionId || DEFAULT_SESSION_ID
console.log(`[Terminal] Client connecting to session: ${sessionId}`)
try {
const session = getOrCreateSession(sessionId)
session.clients.add(ws)
wsToSession.set(ws, sessionId)
// Send connection info
ws.send(JSON.stringify({
type: 'connected',
sessionId: session.id,
isNew: session.outputBuffer.length === 0
}))
// Replay buffer if there's history
if (session.outputBuffer.length > 0) {
console.log(`[Terminal] Replaying ${session.outputBuffer.length} buffer entries`)
ws.send(JSON.stringify({
type: 'replay',
data: session.outputBuffer.join('')
}))
}
console.log(`[Terminal] Client joined session ${sessionId} (${session.clients.size} clients)`)
} catch (e: any) {
console.error('[Terminal] Error:', e)
ws.send(JSON.stringify({ type: 'error', message: e.message }))
}
},
message(ws, message) {
try {
const msg = JSON.parse(message as string)
const sessionId = wsToSession.get(ws)
if (!sessionId) return
const session = sessions.get(sessionId)
if (!session) return
if (msg.type === 'input') {
session.pty.write(msg.data)
} else if (msg.type === 'resize' && msg.cols && msg.rows) {
session.pty.resize(msg.cols, msg.rows)
console.log(`[Terminal] Session ${sessionId} resized to ${msg.cols}x${msg.rows}`)
}
} catch (e: any) {
console.error('[Terminal] Error:', e)
}
},
close(ws) {
const sessionId = wsToSession.get(ws)
if (sessionId) {
const session = sessions.get(sessionId)
if (session) {
session.clients.delete(ws)
console.log(`[Terminal] Client left session ${sessionId} (${session.clients.size} clients remaining)`)
// Don't kill PTY - session persists
}
wsToSession.delete(ws)
}
}
}
})
console.log(`[Terminal] WebSocket running at ws://localhost:${PORT_TERMINAL}`)
return server
}