import express from 'express'; import { WebSocketServer } from 'ws'; import { createServer } from 'http'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { loadFullState, saveFullState, recordItemHistory, saveItems, saveFurnaces, saveAlerts, saveState, loadState, 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'; // CORS intentionally omitted β€” nginx reverse-proxy makes all requests same-origin. // If you need direct server access during dev, add: app.use(require('cors')()) app.disable('x-powered-by'); app.use(express.json({ limit: '5mb' })); // ========== API Key Authentication ========== const API_KEY = process.env.API_KEY || ''; // Validate bearer token from Authorization header or ?key= query param function extractApiKey(req) { const auth = req.headers.authorization || ''; if (auth.startsWith('Bearer ')) return auth.slice(7); return req.query.key || ''; } // Middleware: require API key on mutating endpoints function requireAuth(req, res, next) { if (!API_KEY) return next(); // Auth disabled when no key configured const token = extractApiKey(req); if (token === API_KEY) return next(); return res.status(401).json({ error: 'Unauthorized β€” invalid or missing API key' }); } // Apply auth to all POST/PUT/DELETE routes app.use((req, res, next) => { if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') { return next(); // Read-only endpoints stay open } return requireAuth(req, res, next); }); // ========== Rate Limiting (in-memory, no external dependencies) ========== function createRateLimiter(windowMs, max) { const hits = new Map(); setInterval(() => { const cutoff = Date.now() - windowMs; for (const [key, entry] of hits) { if (entry.start < cutoff) hits.delete(key); } }, windowMs); return (req, res, next) => { const key = req.ip || req.socket.remoteAddress || 'unknown'; const now = Date.now(); const entry = hits.get(key); if (!entry || now - entry.start > windowMs) { hits.set(key, { start: now, count: 1 }); return next(); } entry.count++; if (entry.count > max) { res.set('Retry-After', String(Math.ceil((entry.start + windowMs - now) / 1000))); return res.status(429).json({ error: 'Too many requests β€” try again later' }); } return next(); }; } // 30 mutating requests per minute per IP (excludes bridge state updates) const commandLimiter = createRateLimiter(60_000, 30); app.use((req, res, next) => { if (req.method !== 'POST' || req.path.startsWith('/api/bridge/')) return next(); return commandLimiter(req, res, next); }); // ========== 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; let dropperNicknames = loadState('dropperNicknames', {}); 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 = []; let nextCommandId = 1; // Monotonic ID for deduplication // ========== Idempotent Command Tracking ========== const processedCommands = new Map(); // commandId -> { result, timestamp } const COMMAND_TTL = 5 * 60 * 1000; // 5 minutes // Periodic cleanup of expired entries setInterval(() => { const cutoff = Date.now() - COMMAND_TTL; for (const [id, entry] of processedCommands) { if (entry.timestamp < cutoff) processedCommands.delete(id); } }, 60_000); function checkIdempotent(commandId) { if (!commandId) return null; return processedCommands.get(commandId)?.result || null; } function recordCommand(commandId, result) { if (!commandId) return; processedCommands.set(commandId, { result, timestamp: Date.now() }); } // ========== Helpers ========== function broadcastToClients(data) { const message = JSON.stringify(data); webClients.forEach((client) => { if (client.readyState === 1) { try { client.send(message); } catch (err) { console.error('❌ WS send error (client):', err.message); } } }); } function pushCommandToBridge(command) { let sent = false; for (const bridge of bridgeClients) { if (bridge.readyState === 1) { try { bridge.send(JSON.stringify(command)); sent = true; } catch (err) { console.error('❌ WS send error (bridge):', err.message); } } } if (!sent) { // Fallback: queue for HTTP polling with monotonic ID const id = nextCommandId++; pendingCommands.push({ ...command, id, timestamp: Date.now() }); console.log(`[Bridge] Queued command #${id} 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 (Prominence Hasturian Era II modpack coverage) 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', mythicmetals: 'https://raw.githubusercontent.com/Noaaan/MythicMetals/1.20/src/main/resources/assets/mythicmetals/textures', farmersdelight: 'https://raw.githubusercontent.com/vectorwing/FarmersDelight/1.20/src/main/resources/assets/farmersdelight/textures', ad_astra: 'https://raw.githubusercontent.com/terrarium-earth/Ad-Astra/1.20.1/common/src/main/resources/assets/ad_astra/textures', betterend: 'https://raw.githubusercontent.com/quiqueck/BetterEnd/1.20/src/main/resources/assets/betterend/textures', ae2: 'https://raw.githubusercontent.com/AppliedEnergistics/Applied-Energistics-2/fabric/1.20.1/src/main/resources/assets/ae2/textures', twilightforest: 'https://raw.githubusercontent.com/TeamTwilight/twilightforest/1.20.1/src/main/resources/assets/twilightforest/textures', }; // Ensure cache directory exists fs.mkdirSync(TEXTURE_CACHE_DIR, { recursive: true }); // ── Texture index: maps base_name β†’ relative path for smart resolution ──── // Built on startup by scanning the texture cache. Key = short name (no ext), // Value = array of relative paths (e.g. ["item/diamond.png", "block/diamond_ore.png"]) const textureIndex = {}; // { namespace: { baseName: [relPath, ...] } } function buildTextureIndex() { for (const ns of Object.keys(TEXTURE_UPSTREAMS)) { textureIndex[ns] = {}; const nsDir = path.join(TEXTURE_CACHE_DIR, ns); if (!fs.existsSync(nsDir)) continue; (function walk(dir, rel) { try { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const childRel = rel ? `${rel}/${entry.name}` : entry.name; if (entry.isDirectory()) walk(path.join(dir, entry.name), childRel); else if (entry.name.endsWith('.png') && !entry.name.endsWith('.miss')) { const baseName = entry.name.slice(0, -4); // strip .png if (!textureIndex[ns][baseName]) textureIndex[ns][baseName] = []; textureIndex[ns][baseName].push(childRel); } } } catch (_) { /* ignore unreadable dirs */ } })(nsDir, ''); } const total = Object.values(textureIndex).reduce( (sum, idx) => sum + Object.keys(idx).length, 0); console.log(`[Texture Index] Indexed ${total} unique texture names`); } buildTextureIndex(); // ── Smart texture resolution ────────────────────────────────────────────── // Given a Minecraft item/block registry name, find the best matching texture. // Returns the relative cache path or null. // Patterns for items whose textures use numbered animation frames const ANIMATED_ITEMS = new Set([ 'compass', 'recovery_compass', 'clock', 'crossbow', // crossbow_standby, crossbow_pulling_0, etc. 'light', // light_0 .. light_15 ]); // Maps a registry short name to the actual texture base name const SERVER_ALIASES = { // Registry name differs from texture filename magma_block: 'magma', grass: 'short_grass', scute: 'turtle_scute', enchanted_golden_apple: 'golden_apple', }; // Preferred animation frame for animated items const ANIMATED_FRAME = { compass: 'compass_16', recovery_compass: 'recovery_compass_16', clock: 'clock_22', crossbow: 'crossbow_standby', light: 'light_15', }; // Dye color set for carpet/bed/banner resolution const DYE_COLORS = new Set([ 'white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime', 'pink', 'gray', 'light_gray', 'cyan', 'purple', 'blue', 'brown', 'green', 'red', 'black', ]); const WOOD_TYPES = new Set([ 'oak', 'spruce', 'birch', 'jungle', 'acacia', 'dark_oak', 'mangrove', 'cherry', 'bamboo', 'crimson', 'warped', 'pale_oak', ]); // Blocks whose texture uses a suffix: name_front, name_top, etc. const BLOCK_SUFFIXES = { furnace: '_front_on', blast_furnace: '_front_on', smoker: '_front_on', dispenser: '_front', dropper: '_front', observer: '_front', barrel: '_top', crafting_table: '_top', grass_block: '_top', mycelium: '_top', podzol: '_top', dirt_path: '_top', quartz_block: '_side', pumpkin: '_side', carved_pumpkin: '_front', jack_o_lantern: '_front', jukebox: '_top', loom: '_front', bee_nest: '_front', beehive: '_front', respawn_anchor: '_top', bone_block: '_side', basalt: '_side', polished_basalt: '_side', tnt: '_side', composter: '_side', enchanting_table: '_top', cartography_table: '_top', fletching_table: '_top', smithing_table: '_top', }; // Derivative block suffixes β†’ resolve to parent block const DERIVATIVE_SUFFIXES = [ '_stairs', '_slab', '_fence_gate', '_fence', '_wall', '_button', '_pressure_plate', ]; const STONE_ALIASES = { brick: 'bricks', stone_brick: 'stone_bricks', mossy_stone_brick: 'mossy_stone_bricks', nether_brick: 'nether_bricks', red_nether_brick: 'red_nether_bricks', end_stone_brick: 'end_stone_bricks', deepslate_brick: 'deepslate_bricks', deepslate_tile: 'deepslate_tiles', polished_blackstone_brick: 'polished_blackstone_bricks', mud_brick: 'mud_bricks', quartz: 'quartz_block', purpur: 'purpur_block', smooth_stone: 'smooth_stone', }; // "smooth_" blocks use the parent's top/bottom face const SMOOTH_ALIASES = { smooth_sandstone: 'sandstone_top', smooth_red_sandstone: 'red_sandstone_top', smooth_quartz: 'quartz_block_bottom', }; function resolveTexturePath(ns, itemName) { const idx = textureIndex[ns]; if (!idx) return null; // Helper: check if a baseName exists and return preferred path function find(baseName, preferFolder) { const paths = idx[baseName]; if (!paths || paths.length === 0) return null; if (preferFolder) { const match = paths.find(p => p.startsWith(preferFolder + '/')); if (match) return match; } return paths[0]; } // Helper: check if a baseName with a suffix exists in block/ function findBlock(baseName) { const suffix = BLOCK_SUFFIXES[baseName]; if (suffix) { const hit = find(baseName + suffix, 'block') || find(baseName, 'block'); if (hit) return hit; } return find(baseName, 'block'); } // 1. Direct lookup β€” try item/ then block/ let hit = find(itemName, 'item') || findBlock(itemName); if (hit) return hit; // 2. Server-side alias if (SERVER_ALIASES[itemName]) { const alias = SERVER_ALIASES[itemName]; hit = find(alias, 'item') || findBlock(alias); if (hit) return hit; } // 3. Smooth blocks if (SMOOTH_ALIASES[itemName]) { hit = find(SMOOTH_ALIASES[itemName], 'block'); if (hit) return hit; } // 4. Animated items β€” pick a representative frame if (ANIMATED_FRAME[itemName]) { hit = find(ANIMATED_FRAME[itemName], 'item'); if (hit) return hit; } // 5. Carpets β†’ wool texture if (itemName.endsWith('_carpet')) { const color = itemName.slice(0, -'_carpet'.length); if (DYE_COLORS.has(color)) { hit = find(color + '_wool', 'block'); if (hit) return hit; } } // 6. Wood β†’ log texture if (itemName.endsWith('_wood')) { const base = itemName.slice(0, -'_wood'.length); const stripped = base.startsWith('stripped_') ? base.slice('stripped_'.length) : null; const woodType = stripped || base; if (WOOD_TYPES.has(woodType)) { const logName = stripped ? `stripped_${woodType}_log` : `${woodType}_log`; hit = find(logName, 'block'); if (hit) return hit; } } // 7. Derivative blocks (stairs, slabs, fences, walls…) β†’ parent block for (const suffix of DERIVATIVE_SUFFIXES) { if (!itemName.endsWith(suffix)) continue; const base = itemName.slice(0, -suffix.length); // Try direct parent hit = findBlock(base); if (hit) return hit; // Wood β†’ planks if (WOOD_TYPES.has(base)) { hit = findBlock(base + '_planks'); if (hit) return hit; } // Stone aliases (brick β†’ bricks, etc.) if (STONE_ALIASES[base]) { hit = findBlock(STONE_ALIASES[base]); if (hit) return hit; } break; // only one derivative suffix can match } // 8. Prefix match β€” for items like "compass" try "compass_*" const prefixMatches = Object.keys(idx).filter(k => k.startsWith(itemName + '_')); if (prefixMatches.length > 0) { // Sort for deterministic result, prefer item/ folder prefixMatches.sort(); for (const m of prefixMatches) { hit = find(m, 'item'); if (hit) return hit; } hit = find(prefixMatches[0]); if (hit) return hit; } return null; } // ── Smart resolution endpoint ───────────────────────────────────────────── // GET /api/texture/resolve/:namespace/:itemName // Returns the texture PNG for a Minecraft registry item name, using smart // matching (aliases, animation frames, carpetβ†’wool, etc.) app.get('/api/texture/resolve/:namespace/:itemName', (req, res) => { const { namespace, itemName } = req.params; const name = itemName.replace(/\.png$/i, ''); const resolved = resolveTexturePath(namespace, name); if (!resolved) { return res.status(404).send('No texture found'); } const fullPath = path.join(TEXTURE_CACHE_DIR, namespace, resolved); if (!fs.existsSync(fullPath)) { return res.status(404).send('Texture file missing'); } res.set('Content-Type', 'image/png'); res.set('Cache-Control', 'public, max-age=604800'); res.set('X-Texture-Resolved', resolved); return res.sendFile(fullPath); }); // ── Legacy texture proxy (fallback for cache misses) ────────────────────── app.get('/api/texture/:namespace/*', async (req, res) => { const { namespace } = req.params; const texturePath = req.params[0]; 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, dropperNicknames, }); }); // 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, commandId } = req.body; const cached = checkIdempotent(commandId); if (cached) return res.json(cached); if (!itemName || !amount) { return res.status(400).json({ error: 'Missing itemName or amount' }); } if (typeof itemName !== 'string' || itemName.length > 200) { return res.status(400).json({ error: 'Invalid itemName' }); } const parsedAmount = parseInt(amount); if (!Number.isFinite(parsedAmount) || parsedAmount < 1 || parsedAmount > 100000) { return res.status(400).json({ error: 'Invalid amount (1–100000)' }); } const command = { type: 'command', action: 'order', commandId, itemName, amount: parsedAmount, dropperName: dropperName || null, }; pushCommandToBridge(command); const result = { success: true, commandId, message: `Order sent: ${itemName} x${amount}` }; recordCommand(commandId, result); console.log(`πŸ“¦ Order: ${itemName} x${amount}`); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); // ========== Dropper Nicknames ========== // Get all dropper nicknames app.get('/api/dropper-nicknames', (req, res) => { res.json({ nicknames: dropperNicknames }); }); // Set a single dropper nickname app.post('/api/dropper-nicknames', (req, res) => { try { const { dropperName, nickname, commandId } = req.body; const cached = checkIdempotent(commandId); if (cached) return res.json(cached); if (!dropperName || typeof dropperName !== 'string' || dropperName.length > 200) { return res.status(400).json({ error: 'Missing or invalid dropperName' }); } if (nickname && String(nickname).length > 50) { return res.status(400).json({ error: 'Nickname too long (max 50)' }); } if (nickname && nickname.trim()) { dropperNicknames[dropperName] = nickname.trim(); } else { delete dropperNicknames[dropperName]; } saveState('dropperNicknames', dropperNicknames); console.log(`🏷️ Dropper nickname: ${dropperName} β†’ ${nickname || '(removed)'}`); const result = { success: true, nicknames: dropperNicknames }; recordCommand(commandId, result); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); // Request inventory scan app.post('/api/scan', (req, res) => { try { const { commandId } = req.body || {}; const cached = checkIdempotent(commandId); if (cached) return res.json(cached); pushCommandToBridge({ type: 'command', action: 'scan', commandId }); const result = { success: true, commandId, message: 'Scan requested' }; recordCommand(commandId, result); console.log('πŸ” Scan requested'); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); // Toggle smelting pause app.post('/api/smelting/toggle', (req, res) => { try { const { commandId } = req.body || {}; const cached = checkIdempotent(commandId); if (cached) return res.json(cached); pushCommandToBridge({ type: 'command', action: 'toggle_pause', commandId }); const result = { success: true, commandId }; recordCommand(commandId, result); console.log('πŸ”₯ Smelting toggle requested'); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); // Toggle a specific recipe app.post('/api/recipes/toggle', (req, res) => { try { const { recipe, commandId } = req.body; const cached = checkIdempotent(commandId); if (cached) return res.json(cached); if (!recipe || typeof recipe !== 'string' || recipe.length > 200) { return res.status(400).json({ error: 'Missing or invalid recipe name' }); } pushCommandToBridge({ type: 'command', action: 'toggle_recipe', commandId, recipe }); const result = { success: true, commandId }; recordCommand(commandId, result); console.log(`πŸ”„ Recipe toggle: ${recipe}`); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); // Enable/disable all recipes app.post('/api/recipes/enable-all', (req, res) => { try { const { commandId } = req.body || {}; const cached = checkIdempotent(commandId); if (cached) return res.json(cached); pushCommandToBridge({ type: 'command', action: 'enable_all', commandId }); const result = { success: true, commandId }; recordCommand(commandId, result); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/recipes/disable-all', (req, res) => { try { const { commandId } = req.body || {}; const cached = checkIdempotent(commandId); if (cached) return res.json(cached); pushCommandToBridge({ type: 'command', action: 'disable_all', commandId }); const result = { success: true, commandId }; recordCommand(commandId, result); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); // Remote reboot CC:Tweaked computers app.post('/api/reboot', (req, res) => { try { const { target, commandId } = req.body || {}; const cached = checkIdempotent(commandId); if (cached) return res.json(cached); const validTargets = ['all', 'manager', 'client', 'turtle', 'bridge']; const t = target || 'all'; // Allow valid role names or numeric computer IDs if (!validTargets.includes(t) && !/^\d+$/.test(t)) { return res.status(400).json({ error: 'Invalid target. Use: all, manager, client, turtle, bridge, or a computer ID' }); } pushCommandToBridge({ type: 'command', action: 'reboot', commandId, target: t }); const result = { success: true, commandId, message: `Reboot sent to: ${t}` }; recordCommand(commandId, result); console.log(`πŸ”„ Reboot requested: target=${t}`); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); // Sort barrel app.post('/api/sort-barrel', (req, res) => { try { const { barrelName, commandId } = req.body; const cached = checkIdempotent(commandId); if (cached) return res.json(cached); if (!barrelName) return res.status(400).json({ error: 'Missing barrelName' }); pushCommandToBridge({ type: 'command', action: 'sort_barrel', commandId, barrelName }); const result = { success: true, commandId }; recordCommand(commandId, result); console.log(`πŸ“¦ Sort barrel: ${barrelName}`); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); // Craft item app.post('/api/craft', (req, res) => { try { const { recipeIdx, commandId } = req.body; const cached = checkIdempotent(commandId); if (cached) return res.json(cached); if (recipeIdx === undefined) return res.status(400).json({ error: 'Missing recipeIdx' }); const parsedIdx = parseInt(recipeIdx); if (!Number.isFinite(parsedIdx) || parsedIdx < 1 || parsedIdx > 1000) { return res.status(400).json({ error: 'Invalid recipeIdx' }); } pushCommandToBridge({ type: 'command', action: 'craft', commandId, recipeIdx: parsedIdx }); const result = { success: true, commandId }; recordCommand(commandId, result); console.log(`πŸ”¨ Craft request: recipe #${recipeIdx}`); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); // Recursive craft (multi-step crafting chain) app.post('/api/recursive-craft', (req, res) => { try { const { itemName, count, commandId } = req.body; const cached = checkIdempotent(commandId); if (cached) return res.json(cached); if (!itemName || typeof itemName !== 'string' || itemName.length > 200) { return res.status(400).json({ error: 'Missing or invalid itemName' }); } const parsedCount = parseInt(count); if (!Number.isFinite(parsedCount) || parsedCount < 1 || parsedCount > 100000) { return res.status(400).json({ error: 'Invalid count (1–100000)' }); } pushCommandToBridge({ type: 'command', action: 'recursive_craft', commandId, itemName, count: parsedCount }); const result = { success: true, commandId, message: `Recursive craft sent: ${itemName} x${count}` }; recordCommand(commandId, result); console.log(`πŸ”¨ Recursive craft: ${itemName} x${count}`); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); // Learn a crafting recipe app.post('/api/recipes/learn-crafting', (req, res) => { try { const { output, count, grid, commandId } = req.body; const cached = checkIdempotent(commandId); if (cached) return res.json(cached); if (!output || typeof output !== 'string' || output.length > 200) { return res.status(400).json({ error: 'Missing or invalid output' }); } if (!grid || !Array.isArray(grid) || grid.length !== 9) { return res.status(400).json({ error: 'grid must be an array of 9 items' }); } pushCommandToBridge({ type: 'command', action: 'learn_crafting_recipe', commandId, output, count: count || 1, grid }); const result = { success: true, commandId, message: `Learned crafting recipe: ${output}` }; recordCommand(commandId, result); console.log(`πŸ“– Learn crafting recipe: ${output}`); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); // Learn a smelting recipe app.post('/api/recipes/learn-smelting', (req, res) => { try { const { input, result: recipeResult, furnaces, commandId } = req.body; const cached = checkIdempotent(commandId); if (cached) return res.json(cached); if (!input || typeof input !== 'string' || input.length > 200) { return res.status(400).json({ error: 'Missing or invalid input' }); } if (!recipeResult || typeof recipeResult !== 'string' || recipeResult.length > 200) { return res.status(400).json({ error: 'Missing or invalid result' }); } pushCommandToBridge({ type: 'command', action: 'learn_smelting_recipe', commandId, input, result: recipeResult, furnaces }); const apiResult = { success: true, commandId, message: `Learned smelting recipe: ${input} β†’ ${recipeResult}` }; recordCommand(commandId, apiResult); console.log(`πŸ“– Learn smelting recipe: ${input} β†’ ${recipeResult}`); res.json(apiResult); } catch (error) { res.status(500).json({ error: error.message }); } }); // Forget a recipe app.post('/api/recipes/forget', (req, res) => { try { const { recipe, commandId } = req.body; const cached = checkIdempotent(commandId); if (cached) return res.json(cached); if (!recipe || typeof recipe !== 'string' || recipe.length > 200) { return res.status(400).json({ error: 'Missing or invalid recipe name' }); } pushCommandToBridge({ type: 'command', action: 'forget_recipe', commandId, recipe }); const result = { success: true, commandId, message: `Forget recipe: ${recipe}` }; recordCommand(commandId, result); console.log(`πŸ—‘οΈ Forget recipe: ${recipe}`); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); // Sync disabled recipes state app.post('/api/recipes/sync', (req, res) => { try { const { disabledRecipes, smeltingPaused, commandId } = req.body; const cached = checkIdempotent(commandId); if (cached) return res.json(cached); pushCommandToBridge({ type: 'command', action: 'sync_disabled_recipes', commandId, disabledRecipes, smeltingPaused }); const result = { success: true, commandId, message: 'Synced recipe state' }; recordCommand(commandId, result); console.log('πŸ”„ Sync disabled recipes'); res.json(result); } 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 (auth required β€” contains operational data) app.get('/api/bridge/commands', requireAuth, (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 by last processed ID // Only removes commands with id <= lastProcessedId, preventing // race conditions where new commands arrive between GET and ACK. app.post('/api/bridge/commands/ack', (req, res) => { try { const { lastProcessedId } = req.body || {}; if (lastProcessedId !== undefined && lastProcessedId !== null) { // Remove only commands that have been processed const before = pendingCommands.length; pendingCommands = pendingCommands.filter(cmd => (cmd.id || 0) > lastProcessedId); const cleared = before - pendingCommands.length; res.json({ success: true, cleared }); } else { // Legacy: clear all (backwards-compatible) 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, commandId, success, message, error: err } = req.body; broadcastToClients({ type: 'command_result', commandId, 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, dropperNicknames, 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({ noServer: true, maxPayload: 1 * 1024 * 1024 /* 1 MB */ }); console.log(`πŸš€ Inventory Manager Web Server starting...`); console.log(`πŸ“‘ HTTP Server: http://localhost:${PORT}`); console.log(`πŸ”Œ WebSocket Server: ws://localhost:${PORT}/ws`); if (API_KEY) { console.log('πŸ”’ API key authentication enabled'); } else { console.log('⚠️ No API_KEY set \u2014 authentication disabled (open access)'); } // Authenticate WebSocket upgrades server.on('upgrade', (req, socket, head) => { if (API_KEY) { // Extract key from query string: /ws?key=... or /ws/bridge?key=... const urlObj = new URL(req.url, `http://${req.headers.host}`); const token = urlObj.searchParams.get('key') || ''; if (token !== API_KEY) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return; } } wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req); }); }); 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.isAlive = true; // Notify web clients that the bridge is now connected broadcastToClients({ type: 'state_update', bridgeConnected: true, }); 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 β€” include commandId broadcastToClients({ type: 'command_result', commandId: data.commandId, 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); // Notify web clients that the bridge may be disconnected broadcastToClients({ type: 'state_update', bridgeConnected: bridgeClients.size > 0, }); }); ws.on('pong', () => { ws.isAlive = true; }); ws.on('error', (error) => { console.error('❌ Bridge WS error:', error); bridgeClients.delete(ws); broadcastToClients({ type: 'state_update', bridgeConnected: bridgeClients.size > 0, }); }); 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, dropperNicknames, })); ws.on('pong', () => { ws.isAlive = true; }); ws.on('message', (message) => { try { const data = JSON.parse(message); if (data.type === 'command') { // Idempotency check for WS commands const cached = checkIdempotent(data.commandId); if (cached) { ws.send(JSON.stringify({ type: 'command_result', commandId: data.commandId, ...cached })); return; } // Forward command to bridge pushCommandToBridge(data); if (data.commandId) { recordCommand(data.commandId, { success: true, commandId: data.commandId }); } } } 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 and bridge connections every 25s to keep connections alive const WS_PING_INTERVAL = setInterval(() => { webClients.forEach((ws) => { if (!ws.isAlive) { webClients.delete(ws); return ws.terminate(); } ws.isAlive = false; ws.ping(); }); bridgeClients.forEach((ws) => { if (!ws.isAlive) { console.log('πŸŒ‰ Bridge connection stale β€” terminating'); bridgeClients.delete(ws); broadcastToClients({ type: 'state_update', bridgeConnected: bridgeClients.size > 0 }); return ws.terminate(); } ws.isAlive = false; ws.ping(); }); }, 25000); wss.on('close', () => { clearInterval(WS_PING_INTERVAL); }); // ========== Cross-Project Integration API ========== // These endpoints allow the RemoteTurtle system to query inventory state const TURTLE_SERVER_URL = process.env.TURTLE_SERVER_URL || ''; // e.g. http://turtle-server:3001 // Find where a specific item is stored (for turtles to pick up items) app.get('/api/integration/locate-item', (req, res) => { const { name, minCount } = req.query; if (!name) return res.status(400).json({ error: 'Item name required (?name=minecraft:diamond)' }); const items = inventoryState.itemList || []; const match = items.find(i => i.name === name); if (!match || match.count < (parseInt(minCount) || 1)) { return res.json({ found: false, available: match ? match.count : 0 }); } res.json({ found: true, name: match.name, count: match.count, displayName: match.displayName }); }); // Search items by partial name (for turtle autocomplete/fuzzy matching) app.get('/api/integration/search-items', (req, res) => { const { q, limit } = req.query; if (!q) return res.status(400).json({ error: 'Search query required (?q=diamond)' }); const items = inventoryState.itemList || []; const query = q.toLowerCase(); const results = items .filter(i => i.name.toLowerCase().includes(query) || (i.displayName || '').toLowerCase().includes(query)) .sort((a, b) => b.count - a.count) .slice(0, parseInt(limit) || 20); res.json({ results }); }); // Get storage summary (available space, total items) for turtle decision-making app.get('/api/integration/storage-status', (req, res) => { res.json({ grandTotal: inventoryState.grandTotal || 0, chestCount: inventoryState.chestCount || 0, totalSlots: inventoryState.totalSlots || 0, usedSlots: inventoryState.usedSlots || 0, freeSlots: (inventoryState.totalSlots || 0) - (inventoryState.usedSlots || 0), lastUpdate, bridgeConnected: bridgeClients.size > 0, }); }); // Get full item list (for turtle dump target selection) app.get('/api/integration/items', (req, res) => { const items = inventoryState.itemList || []; res.json({ items: items.map(i => ({ name: i.name, count: i.count })) }); }); // Get alerts (so turtles know what items are running low) app.get('/api/integration/low-stock', (req, res) => { const triggered = (alertsState || []).filter(a => a.triggered !== false); res.json({ alerts: triggered }); }); // Proxy to turtle server for combined dashboard info app.get('/api/integration/turtle-status', async (req, res) => { if (!TURTLE_SERVER_URL) { return res.json({ configured: false, message: 'TURTLE_SERVER_URL not configured' }); } try { const resp = await fetch(`${TURTLE_SERVER_URL}/api/turtles`); const data = await resp.json(); res.json({ configured: true, ...data }); } catch (err) { res.status(502).json({ configured: true, error: `Cannot reach turtle server: ${err.message}` }); } }); // ========== 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`); if (TURTLE_SERVER_URL) { console.log(`🐒 Turtle server integration: ${TURTLE_SERVER_URL}`); } }); // Graceful shutdown function shutdown() { console.log('\nπŸ›‘ Shutting down server...'); try { wss.close(); server.close(); closeDb(); console.log('πŸ’Ύ Database closed'); } catch (err) { console.error('❌ Error during shutdown:', err.message); } process.exit(0); } process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); // Catch unhandled errors to prevent silent crashes process.on('unhandledRejection', (reason) => { console.error('❌ Unhandled rejection:', reason); }); process.on('uncaughtException', (err) => { console.error('❌ Uncaught exception:', err); shutdown(); });