Implement initial server setup with Express, WebSocket support, and API endpoints for inventory management
This commit is contained in:
434
web/server/server.js
Normal file
434
web/server/server.js
Normal file
@@ -0,0 +1,434 @@
|
||||
import express from 'express';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import cors from 'cors';
|
||||
import { createServer } from 'http';
|
||||
|
||||
const app = express();
|
||||
const PORT = 3001;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
|
||||
// ========== State ==========
|
||||
const webClients = new Set();
|
||||
const bridgeClients = new Set();
|
||||
|
||||
// Latest inventory state from the CC:Tweaked bridge
|
||||
let inventoryState = {
|
||||
itemList: [],
|
||||
grandTotal: 0,
|
||||
chestCount: 0,
|
||||
totalSlots: 0,
|
||||
usedSlots: 0,
|
||||
freeSlots: 0,
|
||||
usedRatio: 0,
|
||||
dropperOk: false,
|
||||
barrelOk: false,
|
||||
furnaceCount: 0,
|
||||
furnaceStatus: {},
|
||||
};
|
||||
|
||||
let activityState = {};
|
||||
let alertsState = [];
|
||||
let smeltingPaused = false;
|
||||
let disabledRecipes = {};
|
||||
let smeltableRecipes = {};
|
||||
let craftableRecipes = [];
|
||||
let craftTurtleOk = false;
|
||||
let lastUpdate = 0;
|
||||
|
||||
// Pending commands for bridge HTTP polling (fallback)
|
||||
let pendingCommands = [];
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
function broadcastToClients(data) {
|
||||
const message = JSON.stringify(data);
|
||||
webClients.forEach((client) => {
|
||||
if (client.readyState === 1) {
|
||||
client.send(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function pushCommandToBridge(command) {
|
||||
let sent = false;
|
||||
for (const bridge of bridgeClients) {
|
||||
if (bridge.readyState === 1) {
|
||||
bridge.send(JSON.stringify(command));
|
||||
sent = true;
|
||||
}
|
||||
}
|
||||
if (!sent) {
|
||||
// Fallback: queue for HTTP polling
|
||||
pendingCommands.push({ ...command, timestamp: Date.now() });
|
||||
console.log(`[Bridge] Queued command via HTTP poll (no WS bridge)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== HTTP Server ==========
|
||||
const server = createServer(app);
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', lastUpdate, uptime: process.uptime() });
|
||||
});
|
||||
|
||||
// Get current inventory state
|
||||
app.get('/api/inventory', (req, res) => {
|
||||
res.json({
|
||||
inventory: inventoryState,
|
||||
activity: activityState,
|
||||
alerts: alertsState,
|
||||
smeltingPaused,
|
||||
disabledRecipes,
|
||||
smeltable: smeltableRecipes,
|
||||
craftable: craftableRecipes,
|
||||
craftTurtleOk,
|
||||
lastUpdate,
|
||||
});
|
||||
});
|
||||
|
||||
// Get just the item list
|
||||
app.get('/api/items', (req, res) => {
|
||||
res.json({ items: inventoryState.itemList || [] });
|
||||
});
|
||||
|
||||
// Get furnace status
|
||||
app.get('/api/furnaces', (req, res) => {
|
||||
res.json({
|
||||
furnaceCount: inventoryState.furnaceCount,
|
||||
furnaceStatus: inventoryState.furnaceStatus,
|
||||
smeltingPaused,
|
||||
});
|
||||
});
|
||||
|
||||
// Get alerts
|
||||
app.get('/api/alerts', (req, res) => {
|
||||
res.json({ alerts: alertsState });
|
||||
});
|
||||
|
||||
// Get recipes (smeltable + craftable)
|
||||
app.get('/api/recipes', (req, res) => {
|
||||
res.json({
|
||||
smeltable: smeltableRecipes,
|
||||
craftable: craftableRecipes,
|
||||
disabledRecipes,
|
||||
smeltingPaused,
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Command Endpoints ==========
|
||||
|
||||
// Order an item from storage
|
||||
app.post('/api/order', (req, res) => {
|
||||
try {
|
||||
const { itemName, amount, dropperName } = req.body;
|
||||
if (!itemName || !amount) {
|
||||
return res.status(400).json({ error: 'Missing itemName or amount' });
|
||||
}
|
||||
|
||||
const command = {
|
||||
type: 'command',
|
||||
action: 'order',
|
||||
itemName,
|
||||
amount: parseInt(amount),
|
||||
dropperName: dropperName || null,
|
||||
};
|
||||
|
||||
pushCommandToBridge(command);
|
||||
console.log(`📦 Order: ${itemName} x${amount}`);
|
||||
res.json({ success: true, message: `Order sent: ${itemName} x${amount}` });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Request inventory scan
|
||||
app.post('/api/scan', (req, res) => {
|
||||
try {
|
||||
pushCommandToBridge({ type: 'command', action: 'scan' });
|
||||
console.log('🔍 Scan requested');
|
||||
res.json({ success: true, message: 'Scan requested' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle smelting pause
|
||||
app.post('/api/smelting/toggle', (req, res) => {
|
||||
try {
|
||||
pushCommandToBridge({ type: 'command', action: 'toggle_pause' });
|
||||
console.log('🔥 Smelting toggle requested');
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle a specific recipe
|
||||
app.post('/api/recipes/toggle', (req, res) => {
|
||||
try {
|
||||
const { recipe } = req.body;
|
||||
if (!recipe) return res.status(400).json({ error: 'Missing recipe name' });
|
||||
|
||||
pushCommandToBridge({ type: 'command', action: 'toggle_recipe', recipe });
|
||||
console.log(`🔄 Recipe toggle: ${recipe}`);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Enable/disable all recipes
|
||||
app.post('/api/recipes/enable-all', (req, res) => {
|
||||
pushCommandToBridge({ type: 'command', action: 'enable_all' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post('/api/recipes/disable-all', (req, res) => {
|
||||
pushCommandToBridge({ type: 'command', action: 'disable_all' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Sort barrel
|
||||
app.post('/api/sort-barrel', (req, res) => {
|
||||
try {
|
||||
const { barrelName } = req.body;
|
||||
if (!barrelName) return res.status(400).json({ error: 'Missing barrelName' });
|
||||
|
||||
pushCommandToBridge({ type: 'command', action: 'sort_barrel', barrelName });
|
||||
console.log(`📦 Sort barrel: ${barrelName}`);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Craft item
|
||||
app.post('/api/craft', (req, res) => {
|
||||
try {
|
||||
const { recipeIdx } = req.body;
|
||||
if (recipeIdx === undefined) return res.status(400).json({ error: 'Missing recipeIdx' });
|
||||
|
||||
pushCommandToBridge({ type: 'command', action: 'craft', recipeIdx: parseInt(recipeIdx) });
|
||||
console.log(`🔨 Craft request: recipe #${recipeIdx}`);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Bridge Endpoints (for HTTP polling fallback) ==========
|
||||
|
||||
// Bridge sends inventory state
|
||||
app.post('/api/bridge/state', (req, res) => {
|
||||
try {
|
||||
const data = req.body;
|
||||
updateStateFromBridge(data);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('❌ Error processing bridge state:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Bridge polls for pending commands
|
||||
app.get('/api/bridge/commands', (req, res) => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
// Clear old commands (>30s)
|
||||
pendingCommands = pendingCommands.filter(cmd => (now - cmd.timestamp) < 30000);
|
||||
|
||||
const commands = [...pendingCommands];
|
||||
res.json({ commands });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Bridge acknowledges commands
|
||||
app.post('/api/bridge/commands/ack', (req, res) => {
|
||||
try {
|
||||
const cleared = pendingCommands.length;
|
||||
pendingCommands = [];
|
||||
res.json({ success: true, cleared });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Bridge sends command result
|
||||
app.post('/api/bridge/result', (req, res) => {
|
||||
try {
|
||||
const { action, success, message, error: err } = req.body;
|
||||
|
||||
broadcastToClients({
|
||||
type: 'command_result',
|
||||
action,
|
||||
success,
|
||||
message,
|
||||
error: err,
|
||||
});
|
||||
|
||||
console.log(`📩 Result: ${action} - ${success ? 'OK' : 'FAIL'}`);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== State Update ==========
|
||||
|
||||
function updateStateFromBridge(data) {
|
||||
if (data.cache) {
|
||||
inventoryState = {
|
||||
itemList: data.cache.itemList || [],
|
||||
grandTotal: data.cache.grandTotal || 0,
|
||||
chestCount: data.cache.chestCount || 0,
|
||||
totalSlots: data.cache.totalSlots || 0,
|
||||
usedSlots: data.cache.usedSlots || 0,
|
||||
freeSlots: data.cache.freeSlots || 0,
|
||||
usedRatio: data.cache.usedRatio || 0,
|
||||
dropperOk: data.cache.dropperOk || false,
|
||||
barrelOk: data.cache.barrelOk || false,
|
||||
furnaceCount: data.cache.furnaceCount || 0,
|
||||
furnaceStatus: data.cache.furnaceStatus || {},
|
||||
};
|
||||
}
|
||||
|
||||
if (data.activity !== undefined) activityState = data.activity;
|
||||
if (data.alerts !== undefined) alertsState = data.alerts;
|
||||
if (data.smeltingPaused !== undefined) smeltingPaused = data.smeltingPaused;
|
||||
if (data.disabledRecipes !== undefined) disabledRecipes = data.disabledRecipes;
|
||||
if (data.smeltable !== undefined) smeltableRecipes = data.smeltable;
|
||||
if (data.craftable !== undefined) craftableRecipes = data.craftable;
|
||||
if (data.craftTurtleOk !== undefined) craftTurtleOk = data.craftTurtleOk;
|
||||
|
||||
lastUpdate = Date.now();
|
||||
|
||||
// Broadcast to all web clients
|
||||
broadcastToClients({
|
||||
type: 'state_update',
|
||||
inventory: inventoryState,
|
||||
activity: activityState,
|
||||
alerts: alertsState,
|
||||
smeltingPaused,
|
||||
disabledRecipes,
|
||||
craftTurtleOk,
|
||||
lastUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
// ========== WebSocket Server ==========
|
||||
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
console.log(`🚀 Inventory Manager Web Server starting...`);
|
||||
console.log(`📡 HTTP Server: http://localhost:${PORT}`);
|
||||
console.log(`🔌 WebSocket Server: ws://localhost:${PORT}/ws`);
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const url = req.url || '';
|
||||
|
||||
// ---- Bridge WebSocket connection ----
|
||||
if (url.startsWith('/ws/bridge')) {
|
||||
console.log('🌉 CC:Tweaked bridge connected via WebSocket');
|
||||
bridgeClients.add(ws);
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
|
||||
if (data.type === 'ping') {
|
||||
ws.send(JSON.stringify({ type: 'pong' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'state') {
|
||||
// Full state update from bridge
|
||||
updateStateFromBridge(data);
|
||||
} else if (data.type === 'command_result') {
|
||||
// Command result from bridge
|
||||
broadcastToClients({
|
||||
type: 'command_result',
|
||||
action: data.action,
|
||||
success: data.success,
|
||||
message: data.message,
|
||||
error: data.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Bridge WS message error:', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('🌉 CC:Tweaked bridge disconnected');
|
||||
bridgeClients.delete(ws);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('❌ Bridge WS error:', error);
|
||||
bridgeClients.delete(ws);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Web client WebSocket connection ----
|
||||
console.log('🌐 New web client connected');
|
||||
webClients.add(ws);
|
||||
|
||||
// Send current state to new client
|
||||
ws.send(JSON.stringify({
|
||||
type: 'initial_state',
|
||||
inventory: inventoryState,
|
||||
activity: activityState,
|
||||
alerts: alertsState,
|
||||
smeltingPaused,
|
||||
disabledRecipes,
|
||||
smeltable: smeltableRecipes,
|
||||
craftable: craftableRecipes,
|
||||
craftTurtleOk,
|
||||
lastUpdate,
|
||||
}));
|
||||
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
console.log('📨 Received from web client:', data);
|
||||
|
||||
if (data.type === 'command') {
|
||||
// Forward command to bridge
|
||||
pushCommandToBridge(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error processing web client message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('👋 Web client disconnected');
|
||||
webClients.delete(ws);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('❌ WebSocket error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Start Server ==========
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`✅ Inventory Manager Web Server ready on port ${PORT}`);
|
||||
console.log(`\nBridge HTTP endpoint: http://localhost:${PORT}/api/bridge/state`);
|
||||
console.log(`Bridge WebSocket: ws://localhost:${PORT}/ws/bridge`);
|
||||
console.log(`Web client WebSocket: ws://localhost:${PORT}/ws`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n🛑 Shutting down server...');
|
||||
process.exit(0);
|
||||
});
|
||||
Reference in New Issue
Block a user