Files
hapi/hub/src/socket/server.ts
T

173 lines
6.7 KiB
TypeScript

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<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, SocketData>({
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<string, unknown> | 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<string, unknown> | 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 }
}