OpenClawn / server.js
RaiSantos's picture
Create server.js
06306cb verified
/**
* OpenClaw Gateway Server para Hugging Face Spaces
*
* Arquitetura:
* - Servidor Express otimizado para produção
* - Proxy transparente para API do OpenClaw.ai
* - Suporte a múltiplas APIs de IA gratuitas (OpenRouter, Gemini, Groq)
* - Rate limiting, compression, segurança via helmet
* - Healthcheck endpoint para monitoramento do HF
* - Graceful shutdown com cleanup de recursos
*
* @author Senior DevOps Audit
* @version 1.0.0
*/
'use strict';
// Carrega variáveis de ambiente antes de qualquer import
require('dotenv').config();
const express = require('express');
const helmet = require('helmet');
const compression = require('compression');
const { createProxyMiddleware } = require('http-proxy-middleware');
const rateLimit = require('express-rate-limit');
// =============================================================================
// CONFIGURAÇÃO CENTRALIZADA - Validada na inicialização
// =============================================================================
const CONFIG = {
port: parseInt(process.env.PORT || '7860', 10),
host: process.env.HOST || '0.0.0.0',
nodeEnv: process.env.NODE_ENV || 'production',
openclaw: {
apiKey: process.env.OPENCLAW_API_KEY || '',
baseUrl: process.env.OPENCLAW_BASE_URL || 'https://api.openclaw.ai',
},
ai: {
openrouterKey: process.env.OPENROUTER_API_KEY || '',
geminiKey: process.env.GEMINI_API_KEY || '',
groqKey: process.env.GROQ_API_KEY || '',
},
};
// Validação de configuração crítica na inicialização
// Fail-fast: melhor crashar com mensagem clara do que funcionar incorretamente
function validateConfig() {
const warnings = [];
if (!CONFIG.openclaw.apiKey) {
warnings.push('OPENCLAW_API_KEY não configurada. Proxy para API externa desabilitado.');
}
const hasAnyAI = CONFIG.ai.openrouterKey || CONFIG.ai.geminiKey || CONFIG.ai.groqKey;
if (!hasAnyAI) {
warnings.push('Nenhuma chave de API de IA configurada. Configure OPENROUTER_API_KEY, GEMINI_API_KEY ou GROQ_API_KEY.');
}
warnings.forEach(w => console.warn(`[CONFIG WARN] ${w}`));
return true;
}
// =============================================================================
// INICIALIZAÇÃO DO SERVIDOR EXPRESS
// =============================================================================
const app = express();
// Helmet: Configura headers de segurança HTTP
// Desabilitamos contentSecurityPolicy para não bloquear o iframe do HF
app.use(helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
}));
// Compression: gzip/brotli em todas as respostas
// Reduz bandwidth significativamente para payloads de IA (JSON grandes)
app.use(compression({
level: 6, // Balanço entre CPU e compressão
threshold: 1024, // Comprimir apenas respostas > 1KB
}));
// Body parser: Limite de 10MB para suportar payloads grandes de IA
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Rate limiting: Protege contra abuse e evita esgotar quotas de APIs gratuitas
// 100 requests por 15 minutos por IP é generoso para uso normal
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutos
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Muitas requisições. Tente novamente em 15 minutos.' },
});
app.use(limiter);
// =============================================================================
// ENDPOINT: HEALTHCHECK
// Usado pelo HEALTHCHECK do Dockerfile e pelo HF para verificar se o Space
// está vivo. Deve responder em < 1 segundo e nunca falhar se o servidor está up.
// =============================================================================
app.get('/health', (req, res) => {
// Coleta de métricas de memória para debug
const memUsage = process.memoryUsage();
res.status(200).json({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: Math.floor(process.uptime()),
environment: CONFIG.nodeEnv,
memory: {
rss: `${Math.round(memUsage.rss / 1024 / 1024)}MB`,
heapUsed: `${Math.round(memUsage.heapUsed / 1024 / 1024)}MB`,
heapTotal: `${Math.round(memUsage.heapTotal / 1024 / 1024)}MB`,
},
services: {
openclaw: CONFIG.openclaw.apiKey ? 'configured' : 'not_configured',
openrouter: CONFIG.ai.openrouterKey ? 'configured' : 'not_configured',
gemini: CONFIG.ai.geminiKey ? 'configured' : 'not_configured',
groq: CONFIG.ai.groqKey ? 'configured' : 'not_configured',
},
});
});
// =============================================================================
// ENDPOINT: INTERFACE WEB (Dashboard)
// Página HTML para visualizar o status e configurar integrações
// =============================================================================
app.get('/', (req, res) => {
const html = `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenClaw Gateway - HF Space</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0f0f23;
color: #e0e0e0;
min-height: 100vh;
padding: 2rem;
}
.container { max-width: 900px; margin: 0 auto; }
h1 {
font-size: 2rem;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.5rem;
}
.subtitle { color: #888; margin-bottom: 2rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
.card {
background: #1a1a2e;
border: 1px solid #2a2a4a;
border-radius: 12px;
padding: 1.5rem;
}
.card h3 { color: #6366f1; margin-bottom: 1rem; font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.1em; }
.status { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
.dot { width: 8px; height: 8px; border-radius: 50%; }
.dot.green { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
.dot.red { background: #ef4444; }
.dot.yellow { background: #f59e0b; }
.endpoint {
background: #0f0f23;
border: 1px solid #2a2a4a;
border-radius: 8px;
padding: 1rem;
margin-bottom: 0.5rem;
font-family: monospace;
font-size: 0.85rem;
}
.method {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
margin-right: 0.5rem;
}
.get { background: #065f46; color: #6ee7b7; }
.post { background: #1e3a5f; color: #93c5fd; }
.footer { color: #555; font-size: 0.8rem; text-align: center; margin-top: 2rem; }
#metrics { font-size: 0.85rem; color: #888; }
</style>
</head>
<body>
<div class="container">
<h1>🦅 OpenClaw Gateway</h1>
<p class="subtitle">Hugging Face Space · Node.js ${process.version} · ${CONFIG.nodeEnv}</p>
<div class="grid">
<div class="card">
<h3>Status dos Serviços</h3>
<div class="status">
<div class="dot ${CONFIG.openclaw.apiKey ? 'green' : 'red'}"></div>
<span>OpenClaw API</span>
</div>
<div class="status">
<div class="dot ${CONFIG.ai.openrouterKey ? 'green' : 'yellow'}"></div>
<span>OpenRouter</span>
</div>
<div class="status">
<div class="dot ${CONFIG.ai.geminiKey ? 'green' : 'yellow'}"></div>
<span>Google Gemini</span>
</div>
<div class="status">
<div class="dot ${CONFIG.ai.groqKey ? 'green' : 'yellow'}"></div>
<span>Groq</span>
</div>
</div>
<div class="card">
<h3>Métricas em Tempo Real</h3>
<div id="metrics">Carregando...</div>
</div>
<div class="card">
<h3>Configuração Rápida</h3>
<p style="font-size:0.85rem; color:#888; line-height:1.6">
Configure suas chaves em:<br>
<strong style="color:#6366f1">HF Space Settings</strong><br>
→ Variables and Secrets
</p>
</div>
</div>
<div class="card">
<h3>Endpoints Disponíveis</h3>
<div style="margin-top: 1rem;">
<div class="endpoint">
<span class="method get">GET</span>/health · Healthcheck
</div>
<div class="endpoint">
<span class="method post">POST</span>/api/chat · Chat com IA (OpenRouter/Gemini/Groq)
</div>
<div class="endpoint">
<span class="method post">POST</span>/api/openclaw/execute · Executar task OpenClaw
</div>
<div class="endpoint">
<span class="method get">GET</span>/api/models · Listar modelos disponíveis
</div>
</div>
</div>
<div class="footer">
OpenClaw Gateway v1.0.0 · Rodando em Hugging Face Spaces · Uptime: <span id="uptime">0</span>s
</div>
</div>
<script>
async function updateMetrics() {
try {
const r = await fetch('/health');
const d = await r.json();
document.getElementById('metrics').innerHTML =
'<div>RAM: ' + d.memory.heapUsed + ' / ' + d.memory.heapTotal + '</div>' +
'<div>RSS: ' + d.memory.rss + '</div>' +
'<div>Uptime: ' + d.uptime + 's</div>';
document.getElementById('uptime').textContent = d.uptime;
} catch(e) {}
}
updateMetrics();
setInterval(updateMetrics, 5000);
</script>
</body>
</html>`;
res.status(200).send(html);
});
// =============================================================================
// ENDPOINT: CHAT COM IA
// Roteamento inteligente: usa a primeira API configurada disponível
// Ordem de prioridade: OpenRouter > Gemini > Groq
// =============================================================================
app.post('/api/chat', async (req, res) => {
// Import dinâmico de node-fetch (ESM no CommonJS)
const fetch = (await import('node-fetch')).default;
const { message, model, provider } = req.body;
if (!message) {
return res.status(400).json({ error: 'Campo "message" é obrigatório.' });
}
// Seleção de provider com fallback automático
let selectedProvider = provider || 'auto';
if (selectedProvider === 'auto') {
if (CONFIG.ai.openrouterKey) selectedProvider = 'openrouter';
else if (CONFIG.ai.geminiKey) selectedProvider = 'gemini';
else if (CONFIG.ai.groqKey) selectedProvider = 'groq';
else {
return res.status(503).json({
error: 'Nenhuma API de IA configurada. Adicione OPENROUTER_API_KEY, GEMINI_API_KEY ou GROQ_API_KEY nos Secrets do Space.'
});
}
}
try {
let response;
// --- OpenRouter ---
if (selectedProvider === 'openrouter') {
const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${CONFIG.ai.openrouterKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': `https://huggingface.co/spaces`,
'X-Title': 'OpenClaw HF Gateway',
},
body: JSON.stringify({
model: model || 'google/gemma-2-9b-it:free', // Modelo gratuito de alta qualidade
messages: [{ role: 'user', content: message }],
max_tokens: 4096,
}),
});
if (!r.ok) {
const err = await r.text();
throw new Error(`OpenRouter error ${r.status}: ${err}`);
}
const data = await r.json();
response = {
provider: 'openrouter',
model: data.model,
content: data.choices[0]?.message?.content || '',
usage: data.usage,
};
}
// --- Google Gemini ---
else if (selectedProvider === 'gemini') {
const geminiModel = model || 'gemini-1.5-flash';
const r = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${CONFIG.ai.geminiKey}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: message }] }],
generationConfig: { maxOutputTokens: 8192 },
}),
}
);
if (!r.ok) {
const err = await r.text();
throw new Error(`Gemini error ${r.status}: ${err}`);
}
const data = await r.json();
response = {
provider: 'gemini',
model: geminiModel,
content: data.candidates[0]?.content?.parts[0]?.text || '',
usage: data.usageMetadata,
};
}
// --- Groq ---
else if (selectedProvider === 'groq') {
const r = await fetch('https://api.groq.com/openai/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${CONFIG.ai.groqKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: model || 'llama-3.1-8b-instant', // Ultra-rápido e gratuito
messages: [{ role: 'user', content: message }],
max_tokens: 8192,
}),
});
if (!r.ok) {
const err = await r.text();
throw new Error(`Groq error ${r.status}: ${err}`);
}
const data = await r.json();
response = {
provider: 'groq',
model: data.model,
content: data.choices[0]?.message?.content || '',
usage: data.usage,
};
}
else {
return res.status(400).json({ error: `Provider desconhecido: ${selectedProvider}` });
}
res.json({ success: true, ...response });
} catch (error) {
console.error('[API CHAT ERROR]', error.message);
res.status(500).json({
error: 'Erro ao chamar API de IA',
details: error.message,
});
}
});
// =============================================================================
// ENDPOINT: EXECUTAR TASK OPENCLAW
// Proxy para a API real do OpenClaw.ai com autenticação injetada
// =============================================================================
app.post('/api/openclaw/execute', async (req, res) => {
if (!CONFIG.openclaw.apiKey) {
return res.status(503).json({
error: 'OPENCLAW_API_KEY não configurada. Adicione nos Secrets do Space.'
});
}
const fetch = (await import('node-fetch')).default;
try {
const r = await fetch(`${CONFIG.openclaw.baseUrl}/v1/execute`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${CONFIG.openclaw.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(req.body),
});
const data = await r.json();
res.status(r.status).json(data);
} catch (error) {
console.error('[OPENCLAW EXECUTE ERROR]', error.message);
res.status(500).json({ error: 'Erro ao conectar com OpenClaw API', details: error.message });
}
});
// =============================================================================
// ENDPOINT: LISTAR MODELOS DISPONÍVEIS
// =============================================================================
app.get('/api/models', (req, res) => {
res.json({
providers: {
openrouter: {
configured: !!CONFIG.ai.openrouterKey,
freeModels: [
'google/gemma-2-9b-it:free',
'meta-llama/llama-3.1-8b-instruct:free',
'mistralai/mistral-7b-instruct:free',
'microsoft/phi-3-mini-128k-instruct:free',
'nousresearch/hermes-3-llama-3.1-405b:free',
],
},
gemini: {
configured: !!CONFIG.ai.geminiKey,
freeModels: [
'gemini-1.5-flash',
'gemini-1.5-flash-8b',
'gemini-1.0-pro',
],
},
groq: {
configured: !!CONFIG.ai.groqKey,
freeModels: [
'llama-3.1-8b-instant',
'llama-3.1-70b-versatile',
'mixtral-8x7b-32768',
'gemma2-9b-it',
],
},
},
});
});
// =============================================================================
// PROXY PARA OPENCLAW API (todas as rotas /api/openclaw/*)
// Transparente: injeta autenticação e repassa para o servidor real
// =============================================================================
if (CONFIG.openclaw.apiKey && CONFIG.openclaw.baseUrl) {
app.use('/api/openclaw', createProxyMiddleware({
target: CONFIG.openclaw.baseUrl,
changeOrigin: true,
pathRewrite: { '^/api/openclaw': '' },
on: {
proxyReq: (proxyReq) => {
proxyReq.setHeader('Authorization', `Bearer ${CONFIG.openclaw.apiKey}`);
},
error: (err, req, res) => {
console.error('[PROXY ERROR]', err.message);
res.status(502).json({ error: 'Erro no proxy para OpenClaw API', details: err.message });
},
},
}));
}
// =============================================================================
// HANDLER DE ERROS GLOBAL
// Captura qualquer erro não tratado e retorna resposta JSON padronizada
// CRÍTICO: Sem isso, erros causam crash do processo
// =============================================================================
app.use((err, req, res, _next) => {
console.error('[UNHANDLED ERROR]', err.stack);
res.status(500).json({
error: 'Erro interno do servidor',
message: CONFIG.nodeEnv === 'production' ? 'Verifique os logs.' : err.message,
});
});
// =============================================================================
// HANDLER DE ROTAS NÃO ENCONTRADAS
// =============================================================================
app.use((req, res) => {
res.status(404).json({ error: `Rota não encontrada: ${req.method} ${req.path}` });
});
// =============================================================================
// INICIALIZAÇÃO COM GRACEFUL SHUTDOWN
// Graceful shutdown: fecha conexões existentes antes de matar o processo.
// Evita requests interrompidas no meio quando o HF reinicia o Space.
// =============================================================================
validateConfig();
const server = app.listen(CONFIG.port, CONFIG.host, () => {
console.log(`✅ OpenClaw Gateway iniciado`);
console.log(`🌐 Endereço: http://${CONFIG.host}:${CONFIG.port}`);
console.log(`🔧 Ambiente: ${CONFIG.nodeEnv}`);
console.log(`📊 Node.js: ${process.version}`);
console.log(`💾 Memória máx: ${process.env.NODE_OPTIONS || 'padrão'}`);
});
// Timeout para requests longas (30s para APIs de IA que podem ser lentas)
server.timeout = 30000;
server.keepAliveTimeout = 65000; // Maior que timeout de load balancer do HF (60s)
server.headersTimeout = 66000;
// Graceful shutdown handler
function gracefulShutdown(signal) {
console.log(`\n[SHUTDOWN] Recebido ${signal}. Encerrando servidor...`);
server.close((err) => {
if (err) {
console.error('[SHUTDOWN ERROR]', err);
process.exit(1);
}
// Garbage collection manual se disponível (via --expose-gc)
if (global.gc) {
global.gc();
}
console.log('[SHUTDOWN] Servidor encerrado com sucesso.');
process.exit(0);
});
// Force kill após 10s se o servidor não fechar sozinho
setTimeout(() => {
console.error('[SHUTDOWN TIMEOUT] Forçando encerramento após 10s.');
process.exit(1);
}, 10000);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
// Handler para exceções não capturadas
// Loga o erro mas NÃO derruba o processo (resilência em produção)
process.on('uncaughtException', (err) => {
console.error('[UNCAUGHT EXCEPTION]', err.stack);
// Em produção: log e continua. Em desenvolvimento: considera derrubar
});
process.on('unhandledRejection', (reason) => {
console.error('[UNHANDLED REJECTION]', reason);
});