import { createServer, IncomingMessage, Server, ServerResponse } from 'http'; import { createReadStream } from 'fs'; import { AddressInfo } from 'net'; import { pipeline } from 'stream/promises'; import { getDataSource } from '../db/database'; import { LocalApiSettings, readDesktopSettings } from '../desktop-settings'; import { authenticate, matchRoute } from './router'; import { clearAllTokens } from './auth-store'; import { HttpError, RequestContext, getBearerToken, readJsonBody, sendError, sendJson, sendText } from './http-helpers'; export type LocalApiStatus = 'stopped' | 'starting' | 'running' | 'error'; export interface LocalApiSnapshot { status: LocalApiStatus; host: string | null; port: number | null; baseUrl: string | null; error: string | null; exposeOnLan: boolean; scalarEnabled: boolean; docusaurusEnabled: boolean; } let server: Server | null = null; let currentStatus: LocalApiStatus = 'stopped'; let currentBindHost: string | null = null; let currentBindPort: number | null = null; let currentError: string | null = null; let activeSettings: LocalApiSettings | null = null; function pickBindHost(settings: LocalApiSettings): string { return settings.exposeOnLan ? '0.0.0.0' : '127.0.0.1'; } function buildBaseUrl(host: string, port: number): string { const safeHost = host === '0.0.0.0' ? '127.0.0.1' : host; return `http://${safeHost}:${port}`; } async function sendFile(res: ServerResponse, status: number, filePath: string, contentType: string): Promise { if (!res.headersSent) { res.statusCode = status; res.setHeader('Content-Type', contentType); res.setHeader('Cache-Control', 'no-store'); } await pipeline(createReadStream(filePath), res); } export function getLocalApiSnapshot(): LocalApiSnapshot { const settings = activeSettings ?? readDesktopSettings().localApi; return { status: currentStatus, host: currentBindHost, port: currentBindPort, baseUrl: currentBindHost && currentBindPort ? buildBaseUrl(currentBindHost, currentBindPort) : null, error: currentError, exposeOnLan: settings.exposeOnLan, scalarEnabled: settings.scalarEnabled, docusaurusEnabled: settings.docusaurusEnabled }; } async function handleRequest(req: IncomingMessage, res: ServerResponse, settings: LocalApiSettings): Promise { // CORS for loopback origin only. Local-first; not a public API. const origin = req.headers.origin; const allowOrigin = origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/iu.test(origin) ? origin : 'null'; res.setHeader('Access-Control-Allow-Origin', allowOrigin); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); res.setHeader('Access-Control-Max-Age', '600'); if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); return; } let urlObj: URL; try { urlObj = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); } catch { sendJson(res, 400, { error: 'Invalid URL', errorCode: 'INVALID_URL' }); return; } const requestContext: RequestContext = { method: (req.method ?? 'GET').toUpperCase(), url: urlObj, pathname: urlObj.pathname, headers: req.headers, remoteAddress: req.socket.remoteAddress ?? '', bearerToken: getBearerToken(req.headers) }; const { match, methodNotAllowed } = matchRoute(requestContext.method, requestContext.pathname); if (!match) { if (methodNotAllowed) { sendJson(res, 405, { error: 'Method not allowed', errorCode: 'METHOD_NOT_ALLOWED' }); } else { sendJson(res, 404, { error: 'Not found', errorCode: 'NOT_FOUND' }); } return; } if (match.requiresAuth) { const issued = authenticate(requestContext.bearerToken); if (!issued) { sendJson(res, 401, { error: 'Authentication required', errorCode: 'UNAUTHORIZED' }); return; } } const dataSource = getDataSource() ?? null; if (match.requiresDatabase && (!dataSource || !dataSource.isInitialized)) { sendJson(res, 503, { error: 'Database not initialised', errorCode: 'DB_UNAVAILABLE' }); return; } let bodyCache: unknown | undefined; try { const baseUrl = buildBaseUrl(currentBindHost ?? '127.0.0.1', currentBindPort ?? settings.port); const result = await match.handler({ request: requestContext, settings, baseUrl, dataSource, bodyBuffer: async () => { if (bodyCache === undefined) { bodyCache = await readJsonBody(req); } return bodyCache; } }); if (result.status === 204) { res.statusCode = 204; res.end(); return; } if (result.filePath) { await sendFile(res, result.status, result.filePath, result.contentType ?? 'application/octet-stream'); return; } if (result.rawBody !== undefined) { sendText(res, result.status, result.rawBody, result.contentType ?? 'text/plain; charset=utf-8'); return; } sendJson(res, result.status, result.body); } catch (error) { if (!(error instanceof HttpError)) { console.error('[LocalApi] Request handler error:', error); } sendError(res, error); } } export interface StartResult { ok: boolean; snapshot: LocalApiSnapshot; } export async function startLocalApiServer(settings: LocalApiSettings): Promise { if (server) { await stopLocalApiServer(); } activeSettings = { ...settings, allowedSignalingServers: [...settings.allowedSignalingServers] }; currentStatus = 'starting'; currentError = null; currentBindHost = pickBindHost(settings); currentBindPort = settings.port; const httpServer = createServer((req, res) => { void handleRequest(req, res, activeSettings ?? settings).catch((error) => { console.error('[LocalApi] Unhandled request error:', error); try { sendError(res, error); } catch { // ignore } }); }); return await new Promise((resolve) => { httpServer.once('error', (error) => { currentStatus = 'error'; currentError = (error as Error).message; currentBindPort = null; server = null; activeSettings = null; console.error('[LocalApi] Failed to start:', error); resolve({ ok: false, snapshot: getLocalApiSnapshot() }); }); httpServer.listen(settings.port, pickBindHost(settings), () => { const address = httpServer.address() as AddressInfo | null; server = httpServer; currentStatus = 'running'; currentBindPort = address?.port ?? settings.port; currentError = null; console.log(`[LocalApi] Listening on http://${currentBindHost}:${currentBindPort}`); resolve({ ok: true, snapshot: getLocalApiSnapshot() }); }); }); } export async function stopLocalApiServer(): Promise { const httpServer = server; if (!httpServer) { currentStatus = 'stopped'; currentBindHost = null; currentBindPort = null; activeSettings = null; return getLocalApiSnapshot(); } await new Promise((resolve) => { httpServer.close(() => resolve()); // close() waits for connections; force-close keep-alives so it returns promptly. httpServer.closeAllConnections?.(); }); server = null; currentStatus = 'stopped'; currentBindHost = null; currentBindPort = null; currentError = null; activeSettings = null; clearAllTokens(); console.log('[LocalApi] Stopped'); return getLocalApiSnapshot(); } export async function applyLocalApiSettings(): Promise { const settings = readDesktopSettings().localApi; if (!settings.enabled) { return await stopLocalApiServer(); } // If already running with the same bind config, no-op (settings like // scalarEnabled / allowedSignalingServers are read on every request). if ( server && activeSettings && currentStatus === 'running' && activeSettings.port === settings.port && activeSettings.exposeOnLan === settings.exposeOnLan ) { activeSettings = { ...settings, allowedSignalingServers: [...settings.allowedSignalingServers] }; return getLocalApiSnapshot(); } const result = await startLocalApiServer(settings); return result.snapshot; }