import express from 'express'; import { WebSocketServer } from 'ws'; import cors from 'cors'; import { createServer } from 'http'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { loadFullState, saveFullState, recordItemHistory, saveItems, saveFurnaces, saveAlerts, saveState, getHistory, getHistorySummary, closeDb, flushPendingSave, } from './db.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const PORT = process.env.PORT || 3001; const HOST = process.env.HOST || '0.0.0.0'; app.use(cors()); app.use(express.json({ limit: '5mb' })); // ========== State ========== const webClients = new Set(); const bridgeClients = new Set(); // Load persisted state from SQLite on startup console.log('šŸ’¾ Loading persisted state from database...'); const persisted = loadFullState(); let inventoryState = persisted.inventoryState; let activityState = persisted.activityState; let alertsState = persisted.alertsState; let smeltingPaused = persisted.smeltingPaused; let disabledRecipes = persisted.disabledRecipes; let smeltableRecipes = persisted.smeltableRecipes; let craftableRecipes = persisted.craftableRecipes; let craftTurtleOk = persisted.craftTurtleOk; let lastUpdate = persisted.lastUpdate; if (lastUpdate > 0) { const ago = Math.round((Date.now() - lastUpdate) / 1000); console.log(`šŸ’¾ Restored state from ${ago}s ago (${inventoryState.itemList.length} items)`); } else { console.log('šŸ’¾ No previous state found, starting fresh'); } // 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); // ========== Texture Proxy / Cache ========== const TEXTURE_CACHE_DIR = process.env.TEXTURE_CACHE_DIR || path.join('/data', 'texture-cache'); const NEGATIVE_CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days for 404s // Upstream CDN mapping const TEXTURE_UPSTREAMS = { minecraft: 'https://cdn.jsdelivr.net/gh/InventivetalentDev/minecraft-assets@1.21.4/assets/minecraft/textures', computercraft: 'https://raw.githubusercontent.com/cc-tweaked/CC-Tweaked/mc-1.20.x/projects/common/src/main/resources/assets/computercraft/textures', create: 'https://raw.githubusercontent.com/Creators-of-Create/Create/mc1.20.1/dev/src/main/resources/assets/create/textures', }; // Ensure cache directory exists fs.mkdirSync(TEXTURE_CACHE_DIR, { recursive: true }); app.get('/api/texture/:namespace/*', async (req, res) => { const { namespace } = req.params; const texturePath = req.params[0]; // e.g. "item/diamond.png" const upstream = TEXTURE_UPSTREAMS[namespace]; if (!upstream) { return res.status(404).send('Unknown namespace'); } // Sanitize path to prevent directory traversal const safePath = path.normalize(texturePath).replace(/^(\.\.(\/|\\|$))+/, ''); if (safePath.includes('..')) { return res.status(400).send('Invalid path'); } const cacheFile = path.join(TEXTURE_CACHE_DIR, namespace, safePath); const cacheDir = path.dirname(cacheFile); const missFile = cacheFile + '.miss'; // Check positive cache try { if (fs.existsSync(cacheFile)) { res.set('Content-Type', 'image/png'); res.set('Cache-Control', 'public, max-age=604800'); // 7 days res.set('X-Texture-Cache', 'HIT'); return res.sendFile(cacheFile); } } catch (_) { /* proceed to fetch */ } // Check negative cache (cached 404s) try { if (fs.existsSync(missFile)) { const stat = fs.statSync(missFile); const age = Date.now() - stat.mtimeMs; if (age < NEGATIVE_CACHE_TTL) { res.set('X-Texture-Cache', 'MISS-HIT'); return res.status(404).send('Not found (cached)'); } // Expired negative cache, remove it fs.unlinkSync(missFile); } } catch (_) { /* proceed to fetch */ } // Fetch from upstream const upstreamUrl = `${upstream}/${safePath}`; try { const response = await fetch(upstreamUrl); if (!response.ok) { // Cache the 404 so we don't keep hitting upstream try { fs.mkdirSync(cacheDir, { recursive: true }); fs.writeFileSync(missFile, `${response.status}`); } catch (_) { /* ignore cache write errors */ } res.set('X-Texture-Cache', 'UPSTREAM-MISS'); return res.status(404).send('Not found'); } // Save to disk cache const buffer = Buffer.from(await response.arrayBuffer()); try { fs.mkdirSync(cacheDir, { recursive: true }); fs.writeFileSync(cacheFile, buffer); // Remove any stale negative cache if (fs.existsSync(missFile)) fs.unlinkSync(missFile); } catch (_) { /* ignore cache write errors */ } res.set('Content-Type', response.headers.get('content-type') || 'image/png'); res.set('Cache-Control', 'public, max-age=604800'); res.set('X-Texture-Cache', 'UPSTREAM-HIT'); return res.send(buffer); } catch (err) { console.error(`[Texture Proxy] Error fetching ${upstreamUrl}:`, err.message); return res.status(502).send('Upstream error'); } }); // Clear negative texture cache (.miss files) — call after updating texture mappings app.delete('/api/texture-cache/negative', (req, res) => { let cleared = 0; function walk(dir) { try { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) walk(full); else if (entry.name.endsWith('.miss')) { fs.unlinkSync(full); cleared++; } } } catch (_) { /* ignore */ } } walk(TEXTURE_CACHE_DIR); console.log(`[Texture Cache] Cleared ${cleared} negative cache entries`); res.json({ cleared }); }); // Health check app.get('/api/health', (req, res) => { res.json({ status: 'ok', lastUpdate, uptime: process.uptime(), bridgeConnected: bridgeClients.size > 0, webClients: webClients.size, }); }); // 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, bridgeConnected: bridgeClients.size > 0, }); }); // 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 item count history app.get('/api/history/:itemName', (req, res) => { const limit = parseInt(req.query.limit) || 100; const history = getHistory(req.params.itemName, Math.min(limit, 1000)); res.json({ item: req.params.itemName, history }); }); // Get aggregate storage history (total items over time) app.get('/api/history-summary', (req, res) => { const summary = getHistorySummary(); res.json({ history: summary }); }); // 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) { // Normalize itemList: Lua sends { name, total }, frontend expects { name, count } const rawItems = data.cache.itemList || []; const normalizedItems = Array.isArray(rawItems) ? rawItems.map(item => ({ name: item.name || '', count: item.total !== undefined ? item.total : (item.count || 0), displayName: item.displayName || item.name || '', })) : []; // Normalize furnaceStatus: Lua sends array of { name, type, input, fuel, output, active } // Frontend expects object { furnaceName: { active, input, fuel, output } } const rawFurnaces = data.cache.furnaceStatus || []; let normalizedFurnaces = {}; if (Array.isArray(rawFurnaces)) { rawFurnaces.forEach(f => { if (f && f.name) { normalizedFurnaces[f.name] = { active: f.active || false, type: f.type || 'minecraft:furnace', input: f.input || null, fuel: f.fuel || null, output: f.output || null, }; } }); } else if (typeof rawFurnaces === 'object') { normalizedFurnaces = rawFurnaces; } inventoryState = { itemList: normalizedItems, 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: normalizedFurnaces, droppers: Array.isArray(data.cache.droppers) ? data.cache.droppers : [], }; } if (data.activity !== undefined) activityState = data.activity || {}; // Normalize alerts: Lua sends [{ label, current, min }] (only triggered ones) // Frontend expects [{ item, triggered, current, threshold }] if (data.alerts !== undefined) { const rawAlerts = data.alerts || []; alertsState = Array.isArray(rawAlerts) ? rawAlerts.map(a => ({ item: a.name || a.item || a.label || '', triggered: true, current: a.current || 0, threshold: a.min || a.threshold || 0, })) : []; } if (data.smeltingPaused !== undefined) smeltingPaused = data.smeltingPaused; if (data.disabledRecipes !== undefined) disabledRecipes = data.disabledRecipes || {}; if (data.smeltable !== undefined) smeltableRecipes = data.smeltable || {}; // Normalize craftable: Lua sends { output, count, grid } (grid = flat array of 9) // Frontend expects { output, count, slots } (slots = object { slotIdx: itemName }) if (data.craftable !== undefined) { const rawCraftable = data.craftable || []; craftableRecipes = Array.isArray(rawCraftable) ? rawCraftable.map(recipe => { const slots = {}; if (recipe.grid && Array.isArray(recipe.grid)) { recipe.grid.forEach((item, idx) => { if (item) slots[idx + 1] = item; }); } else if (recipe.slots) { Object.assign(slots, recipe.slots); } return { output: recipe.output || '', count: recipe.count || 1, slots, }; }) : []; } if (data.craftTurtleOk !== undefined) craftTurtleOk = data.craftTurtleOk; lastUpdate = Date.now(); // Broadcast to all web clients FIRST (non-blocking, instant) broadcastToClients({ type: 'state_update', inventory: inventoryState, activity: activityState, alerts: alertsState, smeltingPaused, disabledRecipes, smeltable: smeltableRecipes, craftable: craftableRecipes, craftTurtleOk, lastUpdate, bridgeConnected: bridgeClients.size > 0, }); // Persist to SQLite (debounced — won't block the broadcast) saveFullState({ inventoryState, activityState, alertsState, smeltingPaused, disabledRecipes, smeltableRecipes, craftableRecipes, craftTurtleOk, }); // Record history snapshot (throttled to every 5 min internally) if (inventoryState.itemList?.length) { recordItemHistory(inventoryState.itemList); } } // ========== 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); ws.isAlive = true; // 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, bridgeConnected: bridgeClients.size > 0, })); ws.on('pong', () => { ws.isAlive = true; }); ws.on('message', (message) => { try { const data = JSON.parse(message); 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); }); }); // ========== WebSocket Keep-Alive ========== // Ping all web clients every 25s to keep connections alive through reverse proxies const WS_PING_INTERVAL = setInterval(() => { webClients.forEach((ws) => { if (!ws.isAlive) { webClients.delete(ws); return ws.terminate(); } ws.isAlive = false; ws.ping(); }); }, 25000); wss.on('close', () => { clearInterval(WS_PING_INTERVAL); }); // ========== Start Server ========== server.listen(PORT, HOST, () => { console.log(`āœ… Inventory Manager Web Server ready on ${HOST}:${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 function shutdown() { console.log('\nšŸ›‘ Shutting down server...'); try { closeDb(); console.log('šŸ’¾ Database closed'); } catch (err) { console.error('āŒ Error closing database:', err.message); } process.exit(0); } process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown);