import { createServer } from "node:http"; import { loadConfig } from "./config.js"; import { HttpClient } from "./http/client.js"; import { ProxyPool } from "./http/proxy.js"; import { SearchRunner } from "./run.js"; import { setUserAgents } from "./utils/ua.js"; import { loadApiKey } from "./api-key.js"; import { logger, childLogger } from "./utils/logger.js"; import { formatSearchResponse, formatErrorResponse } from "./output/agent.js"; import { createMCPHandler } from "./mcp.js"; const log = childLogger({ component: "api-server" }); function unauthorized(res, msg = "Unauthorized") { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ success: false, error: { code: "UNAUTHORIZED", message: msg } })); } function badRequest(res, msg) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ success: false, error: { code: "BAD_REQUEST", message: msg } })); } function serverError(res, msg) { if (res.headersSent) return; res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ success: false, error: { code: "INTERNAL_ERROR", message: msg } })); } function parseBody(req) { return new Promise((resolve, reject) => { let data = ""; req.on("data", chunk => data += chunk); req.on("end", () => { try { resolve(JSON.parse(data)); } catch { reject(new Error("Invalid JSON")); } }); req.on("error", reject); }); } function authenticate(req) { const apiKey = loadApiKey(); if (!apiKey) return true; const auth = req.headers["authorization"] || ""; const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; return token === apiKey; } export async function startServer(port = 9876) { const config = await loadConfig(); if (config.http.user_agents) setUserAgents(config.http.user_agents); const httpClient = new HttpClient(config.http); const proxyPool = new ProxyPool(config.proxies, config.proxy); if (config.proxy.enabled) { httpClient.setProxyPool(proxyPool); log.info({ proxyCount: config.proxies.length }, "proxy pool attached to API server"); } const runner = new SearchRunner({ httpClient, config }); const handleMCP = createMCPHandler(runner); const server = createServer(async (req, res) => { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } try { const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); const path = url.pathname; // Health check — no auth required if (path === "/health" && req.method === "GET") { res.writeHead(200, { "Content-Type": "application/json" }); const m = proxyPool.getMetrics(); res.end(JSON.stringify({ success: true, status: "ok", uptime: process.uptime(), proxyCount: config.proxies.length, proxyPool: { total: m.totalProxies, alive: m.alive, dead: m.dead, circuitOpen: m.circuitOpen, requestsTotal: m.requestsTotal, successRate: m.successRate, hourlyUsage: m.hourlyUsageCurrent } })); return; } // Metrics — requires auth if (path === "/metrics" && req.method === "GET") { if (!authenticate(req)) return unauthorized(res); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ success: true, metrics: proxyPool.getMetrics(), proxies: proxyPool.getProxyDetail() })); return; } // Auth required for search endpoints if (!authenticate(req)) return unauthorized(res); if (path === "/search" && req.method === "POST") { const body = await parseBody(req); const { query, type = "image", limit = 10 } = body; if (!query) return badRequest(res, "Missing 'query' field"); const start = Date.now(); const data = await runner.run({ query, type, limit }); const response = JSON.parse(formatSearchResponse(data)); response.execution_time_ms = Date.now() - start; res.writeHead(data.image?.results?.length > 0 || data.web?.results?.length > 0 ? 200 : 404, { "Content-Type": "application/json" }); res.end(JSON.stringify(response)); return; } if (path === "/batch" && req.method === "POST") { const body = await parseBody(req); const { queries = [] } = body; if (!Array.isArray(queries) || queries.length === 0) { return badRequest(res, "Missing or empty 'queries' array"); } if (queries.length > 50) { return badRequest(res, "Maximum 50 queries per batch"); } const logBatch = childLogger({ component: "batch", batchSize: queries.length }); const batchStart = Date.now(); logBatch.info("batch started"); const batchResults = await Promise.allSettled( queries.map(async (q, i) => { const qStart = Date.now(); try { const data = await runner.run({ query: q.query || "", type: q.type || "image", limit: q.limit || 10 }); return { index: i, query: q.query, type: q.type || "image", success: true, execution_time_ms: Date.now() - qStart, results: data, errors: data.errors || [] }; } catch (err) { return { index: i, query: q.query, type: q.type || "image", success: false, execution_time_ms: Date.now() - qStart, error: err.message }; } }) ); const totalMs = Date.now() - batchStart; const ok = batchResults.filter(r => r.status === "fulfilled" && r.value.success).length; const fail = batchResults.filter(r => r.status === "fulfilled" && !r.value.success).length; logBatch.info({ total: queries.length, ok, fail, totalMs }, "batch completed"); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ success: true, batch_size: queries.length, execution_time_ms: totalMs, ok, fail, results: batchResults.map(r => r.status === "fulfilled" ? r.value : { index: -1, query: "unknown", success: false, error: r.reason?.message || "Promise rejected" }), metrics: { pool: proxyPool.getMetrics(), hourly_usage: proxyPool.getMetrics().hourlyUsageCurrent } })); return; } // MCP JSON-RPC endpoint if (path === "/mcp" && req.method === "POST") { const body = await parseBody(req); const mcpResp = await handleMCP(body); const status = mcpResp.error ? 400 : 200; res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(mcpResp)); return; } // 404 res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ success: false, error: { code: "NOT_FOUND", message: `No endpoint: ${req.method} ${path}` } })); } catch (err) { log.error({ error: err.message }, "API error"); serverError(res, err.message); } }); server.listen(port, () => { log.info({ port }, "API server started"); console.log(`\n PolySearch API running on http://localhost:${port}`); console.log(` Health: http://localhost:${port}/health`); console.log(` Search: POST http://localhost:${port}/search`); console.log(` Batch: POST http://localhost:${port}/batch`); console.log(` Metrics: GET http://localhost:${port}/metrics`); console.log(` MCP: POST http://localhost:${port}/mcp`); const key = loadApiKey(); if (key) { console.log(` Auth: Authorization: Bearer `); console.log(` Key: ${key.substring(0, 8)}...${key.slice(-4)}\n`); } else { console.log(` Auth: none (no API key configured)\n`); } }); const shutdown = () => { log.info("shutting down API server"); proxyPool.destroy(); server.close(() => process.exit(0)); }; process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); return server; }