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); });