Files
Inventory-Manager-CC/web/server/server.js
MayaTheShy 9f322003db Add SQLite persistence + official Minecraft item icons
Database (better-sqlite3):
- Persist items, furnaces, alerts, recipes, settings to SQLite
- Auto-restore last known state when server restarts or bridge disconnects
- Item count history tracking (5-min snapshots, 7-day retention)
- /api/history/:itemName endpoint for item count history
- Docker volume for database file persistence
- Graceful shutdown with DB connection cleanup

Icons:
- Replace mc-heads.net with official Minecraft game textures via CDN
- Cascading fallback: item texture -> block texture -> emoji
- In-memory URL cache to avoid redundant network requests
- Block texture suffix mapping (furnace_front, barrel_top, etc.)
- Crisp pixel-art rendering with image-rendering: pixelated
2026-03-21 18:10:44 -04:00

535 lines
15 KiB
JavaScript

import express from 'express';
import { WebSocketServer } from 'ws';
import cors from 'cors';
import { createServer } from 'http';
import {
loadFullState, saveFullState, recordItemHistory,
saveItems, saveFurnaces, saveAlerts, saveState,
getHistory, closeDb,
} from './db.js';
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);
// 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 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 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,
};
}
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();
// Persist to SQLite
try {
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);
}
} catch (err) {
console.error('❌ DB save error:', err.message);
}
// 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, 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);