Files
Inventory-Manager-CC/web/server/server.js

1194 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
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,
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 (1100000)' });
}
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 (1100000)' });
}
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();
});