import { Server as Engine } from '@socket.io/bun-engine' import { Server, type DefaultEventsMap } from 'socket.io' import { jwtVerify } from 'jose' import { z } from 'zod' import type { Store } from '../store' import { getConfiguration } from '../configuration' import { resolveAccessToken } from '../utils/accessToken' import { registerCliHandlers } from './handlers/cli' import { registerTerminalHandlers } from './handlers/terminal' import { RpcRegistry } from './rpcRegistry' import { SOCKET_MAX_HTTP_BUFFER_SIZE } from './socketLimits' import type { SyncEvent } from '../sync/syncEngine' import { TerminalRegistry } from './terminalRegistry' import { clearUserTerminalBuffer } from './userTerminalBuffer' import type { CliSocketWithData, SocketData, SocketServer } from './socketTypes' const jwtPayloadSchema = z.object({ uid: z.number(), ns: z.string() }) const DEFAULT_IDLE_TIMEOUT_MS = 15 * 60_000 const DEFAULT_MAX_TERMINALS = 4 function resolveEnvNumber(name: string, fallback: number): number { const raw = process.env[name] if (!raw) { return fallback } const parsed = Number.parseInt(raw, 10) return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback } export type SocketServerDeps = { store: Store jwtSecret: Uint8Array corsOrigins?: string[] getSession?: (sessionId: string) => { active: boolean; namespace: string } | null onWebappEvent?: (event: SyncEvent) => void onSessionAlive?: (payload: { sid: string; time: number; thinking?: boolean; mode?: 'local' | 'remote' }) => void onSessionReady?: (payload: { sid: string; time: number }) => void onSessionEnd?: (payload: { sid: string; time: number }) => void onMachineAlive?: (payload: { machineId: string; time: number; health?: unknown }) => void onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void onSessionActivity?: (sessionId: string, updatedAt: number) => void onSweepImmediateQueued?: (sessionId: string, now: number) => void onMessagesConsumed?: (sessionId: string) => void } export function createSocketServer(deps: SocketServerDeps): { io: SocketServer engine: Engine rpcRegistry: RpcRegistry } { const configuration = getConfiguration() const corsOrigins = deps.corsOrigins ?? configuration.corsOrigins const allowAllOrigins = corsOrigins.includes('*') const corsOriginOption = allowAllOrigins ? '*' : corsOrigins const corsOptions = { origin: corsOriginOption, methods: ['GET', 'POST'], credentials: false } const io = new Server({ cors: corsOptions, maxHttpBufferSize: SOCKET_MAX_HTTP_BUFFER_SIZE }) const engine = new Engine({ path: '/socket.io/', cors: corsOptions, maxHttpBufferSize: SOCKET_MAX_HTTP_BUFFER_SIZE, allowRequest: async (req) => { const origin = req.headers.get('origin') if (!origin || allowAllOrigins || corsOrigins.includes(origin)) { return } throw 'Origin not allowed' } }) io.bind(engine) const rpcRegistry = new RpcRegistry() const idleTimeoutMs = resolveEnvNumber('HAPI_TERMINAL_IDLE_TIMEOUT_MS', DEFAULT_IDLE_TIMEOUT_MS) const maxTerminals = resolveEnvNumber('HAPI_TERMINAL_MAX_TERMINALS', DEFAULT_MAX_TERMINALS) const maxTerminalsPerSocket = maxTerminals const maxTerminalsPerSession = maxTerminals const cliNs = io.of('/cli') const terminalNs = io.of('/terminal') const terminalRegistry = new TerminalRegistry({ idleTimeoutMs, // Release the per-terminal scrollback buffer whenever a terminal is // genuinely removed (close / idle / CLI gone) so it // doesn't accumulate in the hub for the process's life. Reconnect // re-registers skip this (remove(id, false)) to keep their buffer. onRemove: (entry) => clearUserTerminalBuffer(entry.sessionId, entry.terminalId), onIdle: (entry) => { const terminalSocket = terminalNs.sockets.get(entry.socketId) terminalSocket?.emit('terminal:error', { terminalId: entry.terminalId, message: 'Terminal closed due to inactivity.' }) const cliSocket = cliNs.sockets.get(entry.cliSocketId) cliSocket?.emit('terminal:close', { sessionId: entry.sessionId, terminalId: entry.terminalId }) } }) cliNs.use((socket, next) => { const auth = socket.handshake.auth as Record | undefined const token = typeof auth?.token === 'string' ? auth.token : null const parsedToken = token ? resolveAccessToken(token, configuration.cliApiToken, deps.store.accessTokens) : null if (!parsedToken) { return next(new Error('Invalid token')) } socket.data.namespace = parsedToken.namespace next() }) cliNs.on('connection', (socket) => registerCliHandlers(socket as CliSocketWithData, { io, store: deps.store, rpcRegistry, terminalRegistry, onSessionAlive: deps.onSessionAlive, onSessionReady: deps.onSessionReady, onSessionEnd: deps.onSessionEnd, onMachineAlive: deps.onMachineAlive, onWebappEvent: deps.onWebappEvent, onBackgroundTaskDelta: deps.onBackgroundTaskDelta, onSessionActivity: deps.onSessionActivity, onSweepImmediateQueued: deps.onSweepImmediateQueued, onMessagesConsumed: deps.onMessagesConsumed })) terminalNs.use(async (socket, next) => { const auth = socket.handshake.auth as Record | undefined const token = typeof auth?.token === 'string' ? auth.token : null if (!token) { return next(new Error('Missing token')) } try { const verified = await jwtVerify(token, deps.jwtSecret, { algorithms: ['HS256'] }) const parsed = jwtPayloadSchema.safeParse(verified.payload) if (!parsed.success) { return next(new Error('Invalid token payload')) } socket.data.userId = parsed.data.uid socket.data.namespace = parsed.data.ns next() return } catch { return next(new Error('Invalid token')) } }) terminalNs.on('connection', (socket) => registerTerminalHandlers(socket, { io, getSession: (sessionId) => { return deps.getSession?.(sessionId) ?? deps.store.sessions.getSession(sessionId) }, terminalRegistry, maxTerminalsPerSocket, maxTerminalsPerSession })) return { io, engine, rpcRegistry } }