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

1278 lines
44 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 fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
import {
loadFullState, saveFullState, recordItemHistory,
saveItems, saveFurnaces, saveAlerts, saveState, loadState,
getHistory, getHistorySummary, closeDb, flushPendingSave,
} from './db.js';
const require = createRequire(import.meta.url);
const {
createPlatformServer,
createWebSocketManager,
setupGracefulShutdown,
createRateLimiter,
createProxyEndpoint,
} = require('@cc-platform/server');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ========== Platform Server Setup ==========
// Provides Express app, HTTP server, auth middleware, and health endpoint.
// CORS intentionally disabled — nginx reverse-proxy makes all requests same-origin.
const { app, server, auth, start, port } = createPlatformServer({
serviceName: 'inventory-manager',
cors: false,
rateLimit: false, // custom rate limiting below (excludes bridge endpoints)
healthExtras: () => ({
lastUpdate,
bridgeConnected: bridgeClients.size > 0,
webClients: webClients.size,
}),
});
const { requireAuth } = auth;
// API key reference needed for WebSocket upgrade authentication
const API_KEY = process.env.API_KEY || '';
// Custom rate limiting: 30 mutating requests/min per IP, excluding bridge endpoints
const commandLimiter = createRateLimiter({ windowMs: 60000, maxRequests: 30 });
app.use((req, res, next) => {
if (req.path.startsWith('/api/bridge/')) return next();
return commandLimiter(req, res, next);
});
// ========== WebSocket Manager (platform-managed) ==========
// Replaces hand-rolled WebSocketServer with createWebSocketManager() from
// @cc-platform/server. Handles bridge/client URL routing, API key auth on
// upgrade, ping/pong keepalive with stale-connection cleanup.
//
// Bridge path: /ws/bridge — receives state/results, pushes commands real-time
// Client path: /ws — receives initial_state on connect, sends commands
//
// HTTP polling fallback preserved via /api/bridge/* endpoints until WS
// adoption is fully verified.
const wsManager = createWebSocketManager(server, {
apiKey: API_KEY,
// ---- Bridge lifecycle ----
onBridgeConnect: (ws) => {
console.log('\u{1F309} CC:Tweaked bridge connected via WebSocket');
wsManager.broadcastToClients({ type: 'state_update', bridgeConnected: true });
// Flush any pending commands queued while no WS bridge was connected.
// Replaces the implicit sync that HTTP polling previously provided.
const pending = getPendingCommands();
if (pending.length > 0) {
try {
ws.send(JSON.stringify({ type: 'command_batch', commands: pending }));
console.log(`[Bridge] Flushed ${pending.length} pending command(s) via WS`);
} catch (e) {
console.error('[Bridge] Failed to flush pending commands:', e.message);
}
}
},
onBridgeMessage: (ws, data) => {
if (data.type === 'state') {
// Full state update from bridge (same path as POST /api/bridge/state)
updateStateFromBridge(data);
} else if (data.type === 'command_result') {
// Command result from bridge — forward to all web clients
wsManager.broadcastToClients({
type: 'command_result',
commandId: data.commandId,
action: data.action,
success: data.success,
message: data.message,
error: data.error,
});
}
},
onBridgeDisconnect: () => {
console.log('\u{1F309} CC:Tweaked bridge disconnected');
wsManager.broadcastToClients({
type: 'state_update',
bridgeConnected: wsManager.bridgeClients.size > 0,
});
},
// ---- Web client lifecycle ----
onClientConnect: (ws) => {
console.log('\u{1F310} New web client connected');
// Send full current state to newly connected dashboard
ws.send(JSON.stringify({
type: 'initial_state',
inventory: inventoryState,
activity: activityState,
alerts: alertsState,
smeltingPaused,
disabledRecipes,
smeltable: smeltableRecipes,
craftable: craftableRecipes,
craftTurtleOk,
lastUpdate,
bridgeConnected: wsManager.bridgeClients.size > 0,
dropperNicknames,
}));
},
onClientMessage: (ws, data) => {
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 (WS push or HTTP poll queue)
pushCommandToBridge(data);
if (data.commandId) {
recordCommand(data.commandId, { success: true, commandId: data.commandId });
}
}
},
onClientDisconnect: () => {
console.log('\u{1F44B} Web client disconnected');
},
});
// Aliases — backward compatibility for code referencing these Sets directly
const { bridgeClients, webClients } = wsManager;
// ========== State ==========
// 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 ==========
// Broadcasts data to all connected web dashboard clients.
// Delegates to platform WS manager (handles JSON serialization, error handling).
function broadcastToClients(data) {
wsManager.broadcastToClients(data);
}
// Returns a filtered copy of pending commands (clears expired entries).
// Used by both the HTTP polling endpoint and WS initial sync.
function getPendingCommands() {
const now = Date.now();
pendingCommands = pendingCommands.filter(cmd => (now - cmd.timestamp) < 30000);
return [...pendingCommands];
}
// Pushes a command to the CC:Tweaked bridge.
// Primary: real-time push via WebSocket (wsManager.sendToBridge).
// Fallback: queues for HTTP polling if no WS bridge is connected.
function pushCommandToBridge(command) {
const sent = wsManager.sendToBridge(command);
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)`);
}
}
// ========== 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 endpoint provided by platform (createPlatformServer) with custom extras
// 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 commands = getPendingCommands();
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);
}
}
// ========== Server Startup Info ==========
// WebSocket management is handled by createWebSocketManager() (initialized above).
// Bridge: /ws/bridge | Client: /ws | Keepalive: 25s ping/pong (platform default)
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)');
}
// ========== 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
createProxyEndpoint(app, '/api/integration/turtle-status', 'TURTLE_SERVER_URL', '/api/turtles');
// ========== Start Server ==========
setupGracefulShutdown({
serviceName: 'inventory-manager',
cleanup: [
() => wsManager.close(),
() => server.close(),
() => { closeDb(); console.log('💾 Database closed'); },
],
});
start(() => {
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}`);
}
});