feat(oracle): express + websocket server with trace/command endpoints
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,261 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* server.js — OracleBot entrypoint.
|
||||||
|
*
|
||||||
|
* Express HTTP server + WebSocket server providing:
|
||||||
|
* POST /trace — receive tool traces from the gateway
|
||||||
|
* POST /command — accept follow/scan commands
|
||||||
|
* GET /health — liveness / status check
|
||||||
|
* Static files from public/
|
||||||
|
* WS /ws — live push to dashboard clients
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
|
||||||
|
const OracleBot = require('./bot.js');
|
||||||
|
const WorldState = require('./world-state.js');
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Config
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.PORT || '3333', 10);
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 5000; // idle pulse
|
||||||
|
const ACTIVE_INTERVAL_MS = 1000; // active world-snapshot push
|
||||||
|
|
||||||
|
const MAX_WS_TOTAL = 100; // global WebSocket cap
|
||||||
|
const MAX_WS_PER_IP = 5; // per-IP WebSocket cap
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Instances
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const bot = new OracleBot();
|
||||||
|
const state = new WorldState(bot);
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Express app
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(express.json({ limit: '64kb' }));
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// ── POST /trace ──────────────────────────────
|
||||||
|
|
||||||
|
app.post('/trace', (req, res) => {
|
||||||
|
const body = req.body;
|
||||||
|
|
||||||
|
if (!body || typeof body.tool !== 'string') {
|
||||||
|
return res.status(400).json({ ok: false, error: 'Missing required field: tool' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevPlayer = state.activePlayer;
|
||||||
|
|
||||||
|
state.onTrace(body);
|
||||||
|
|
||||||
|
// Broadcast: trace event, mode change, then world snapshot
|
||||||
|
broadcast(state.buildTraceEvent(body));
|
||||||
|
broadcast(state.buildModeChange());
|
||||||
|
|
||||||
|
const snapshot = state.buildWorldSnapshot();
|
||||||
|
if (snapshot) broadcast(snapshot);
|
||||||
|
|
||||||
|
// If the active player changed, follow them
|
||||||
|
if (state.activePlayer && state.activePlayer !== prevPlayer) {
|
||||||
|
try {
|
||||||
|
bot.followPlayer(state.activePlayer);
|
||||||
|
} catch (err) {
|
||||||
|
// Bot may not be spawned yet — non-fatal
|
||||||
|
console.warn(`[server] followPlayer failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /command ─────────────────────────────
|
||||||
|
|
||||||
|
app.post('/command', (req, res) => {
|
||||||
|
const { action, target, center, radius } = req.body || {};
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
return res.status(400).json({ ok: false, error: 'Missing required field: action' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'follow') {
|
||||||
|
if (!target || typeof target !== 'string') {
|
||||||
|
return res.status(400).json({ ok: false, error: 'follow requires target (player name)' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
bot.followPlayer(target);
|
||||||
|
return res.json({ ok: true, action: 'follow', target });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(503).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'scan') {
|
||||||
|
if (!center || typeof center.x !== 'number' || typeof center.y !== 'number' || typeof center.z !== 'number') {
|
||||||
|
return res.status(400).json({ ok: false, error: 'scan requires center {x, y, z}' });
|
||||||
|
}
|
||||||
|
const r = typeof radius === 'number' ? radius : 16;
|
||||||
|
try {
|
||||||
|
const blocks = bot.scanArea(center.x, center.y, center.z, r);
|
||||||
|
const entities = bot.getNearbyEntities(center.x, center.y, center.z, r);
|
||||||
|
return res.json({ ok: true, action: 'scan', center, radius: r, blocks, entities });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(503).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(400).json({ ok: false, error: `Unknown action: ${action}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /health ───────────────────────────────
|
||||||
|
|
||||||
|
app.get('/health', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
botConnected: bot._spawned === true,
|
||||||
|
wsClients: wss.clients.size,
|
||||||
|
mode: state.mode,
|
||||||
|
activePlayer: state.activePlayer,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// HTTP server
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const server = http.createServer(app);
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// WebSocket server
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||||
|
|
||||||
|
// Track connections per IP: Map<ip, number>
|
||||||
|
const ipCounts = new Map();
|
||||||
|
|
||||||
|
wss.on('connection', (ws, req) => {
|
||||||
|
// Resolve client IP (trust x-forwarded-for from Caddy)
|
||||||
|
const forwarded = req.headers['x-forwarded-for'];
|
||||||
|
const ip = forwarded ? forwarded.split(',')[0].trim() : (req.socket.remoteAddress || 'unknown');
|
||||||
|
|
||||||
|
// Enforce total cap
|
||||||
|
if (wss.clients.size > MAX_WS_TOTAL) {
|
||||||
|
ws.close(1008, 'Server at capacity');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce per-IP cap
|
||||||
|
const currentCount = ipCounts.get(ip) || 0;
|
||||||
|
if (currentCount >= MAX_WS_PER_IP) {
|
||||||
|
ws.close(1008, 'Too many connections from your IP');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ipCounts.set(ip, currentCount + 1);
|
||||||
|
|
||||||
|
// Store IP on the socket for cleanup
|
||||||
|
ws._remoteIp = ip;
|
||||||
|
|
||||||
|
// Send current state immediately on connect
|
||||||
|
ws.send(JSON.stringify(state.buildHeartbeat()));
|
||||||
|
|
||||||
|
// If we're not idle, also send current mode
|
||||||
|
if (state.mode !== 'idle') {
|
||||||
|
ws.send(JSON.stringify(state.buildModeChange()));
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
const count = ipCounts.get(ws._remoteIp) || 1;
|
||||||
|
if (count <= 1) {
|
||||||
|
ipCounts.delete(ws._remoteIp);
|
||||||
|
} else {
|
||||||
|
ipCounts.set(ws._remoteIp, count - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast a message object to all connected WebSocket clients.
|
||||||
|
* @param {Object} msg
|
||||||
|
*/
|
||||||
|
function broadcast(msg) {
|
||||||
|
if (wss.clients.size === 0) return;
|
||||||
|
const payload = JSON.stringify(msg);
|
||||||
|
for (const client of wss.clients) {
|
||||||
|
if (client.readyState === client.OPEN) {
|
||||||
|
client.send(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Periodic updates
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Heartbeat every 5s (idle mode pulse + idle timeout check)
|
||||||
|
setInterval(() => {
|
||||||
|
if (wss.clients.size === 0) return;
|
||||||
|
|
||||||
|
// Check if session has timed out
|
||||||
|
const wentIdle = state.checkIdle();
|
||||||
|
if (wentIdle) {
|
||||||
|
broadcast(state.buildModeChange());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.mode === 'idle') {
|
||||||
|
broadcast(state.buildHeartbeat());
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_INTERVAL_MS);
|
||||||
|
|
||||||
|
// World snapshot every 1s (active mode only)
|
||||||
|
setInterval(() => {
|
||||||
|
if (wss.clients.size === 0) return;
|
||||||
|
if (state.mode === 'idle') return;
|
||||||
|
|
||||||
|
const snapshot = state.buildWorldSnapshot();
|
||||||
|
if (snapshot) broadcast(snapshot);
|
||||||
|
}, ACTIVE_INTERVAL_MS);
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Bot event handlers
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
bot.on('connected', () => {
|
||||||
|
broadcast(state.buildStatus(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.on('disconnected', () => {
|
||||||
|
broadcast(state.buildStatus(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.on('playerJoined', () => {
|
||||||
|
broadcast(state.buildHeartbeat());
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.on('playerLeft', () => {
|
||||||
|
broadcast(state.buildHeartbeat());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Startup
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
bot.connect();
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`[oracle-bot] HTTP + WS server listening on port ${PORT}`);
|
||||||
|
console.log(`[oracle-bot] WebSocket path: ws://localhost:${PORT}/ws`);
|
||||||
|
console.log(`[oracle-bot] Health check: http://localhost:${PORT}/health`);
|
||||||
|
console.log(`[oracle-bot] Connecting bot to Minecraft at ${bot.host}:${bot.port} ...`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user