import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import { log } from '../logger' export type GsiHandler = ( payload: unknown, headers: Record ) => void const PORT = 4701 /** * Hard cap on incoming POST body. Real Dota GSI payloads are ~8 KB; anything * larger is either a bug or a malicious local client trying to OOM us. */ const MAX_BODY_BYTES = 256 * 1024 let server: Server | null = null const handlers: Map = new Map() function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { let received = 0 const chunks: Buffer[] = [] req.on('data', (c: Buffer) => { received += c.length if (received > MAX_BODY_BYTES) { // Drop the connection so we don't keep buffering. req.destroy(new Error('body too large')) reject(new Error('body too large')) return } chunks.push(c) }) req.on('end', () => resolve(Buffer.concat(chunks))) req.on('error', reject) }) } async function onRequest( req: IncomingMessage, res: ServerResponse ): Promise { // Reject browser-originated requests outright. Legitimate Dota GSI POSTs // never include an Origin header; any value here means a webpage is poking // our localhost endpoint via cross-origin fetch, which we never want. if (req.headers.origin) { res.statusCode = 403 res.end() return } // Same intent for Sec-Fetch-Site: browsers always set it, Dota never does. if (req.headers['sec-fetch-site']) { res.statusCode = 403 res.end() return } const route = (req.url ?? '/').split('?')[0] const handler = handlers.get(route) if (!handler) { res.statusCode = 404 res.end() return } if (req.method !== 'POST') { res.statusCode = 405 res.end() return } // Require JSON content-type. Browsers' "simple" requests in no-cors mode // can only send text/plain or form-encoded — locking to application/json // shrinks the cross-origin attack surface further. const ct = String(req.headers['content-type'] ?? '').toLowerCase() if (!ct.includes('application/json')) { res.statusCode = 415 res.end() return } let payload: unknown try { const body = await readBody(req) const text = body.toString('utf-8') payload = text.length > 0 ? JSON.parse(text) : {} } catch (err) { // Log the real reason locally; do not echo it to the client. log.warn('[gsi] bad request', err instanceof Error ? err.message : err) res.statusCode = 400 res.end() return } try { handler(payload, req.headers) res.statusCode = 200 res.setHeader('Content-Type', 'text/plain') res.end('ok') } catch (err) { log.error('[gsi] handler threw', err) res.statusCode = 500 res.end() } } export async function startGsiServer(): Promise { if (server) return await new Promise((resolve, reject) => { server = createServer(onRequest) server.once('error', reject) server.listen(PORT, '127.0.0.1', () => resolve()) }) } export async function stopGsiServer(): Promise { if (!server) return const s = server server = null // Free pending sockets so close() can resolve quickly even while Dota holds // a long-poll connection open. s.closeAllConnections?.() await new Promise((resolve) => s.close(() => resolve())) } export function registerGsiRoute( route: string, handler: GsiHandler ): () => void { handlers.set(route, handler) return () => { // Only delete if we're still the registered handler — protects against // double-register + unregister races where a newer handler took our slot. if (handlers.get(route) === handler) handlers.delete(route) } } export function getGsiBaseUrl(): string { return `http://127.0.0.1:${PORT}` } export function getGsiPort(): number { return PORT }