From bbae2740a7d3abd93313a6d5d55d4eadff867d20 Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Sun, 22 Mar 2026 02:12:18 -0400 Subject: [PATCH] Implement idempotent command tracking with cleanup and caching for improved request handling --- web/server/server.js | 127 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 106 insertions(+), 21 deletions(-) diff --git a/web/server/server.js b/web/server/server.js index 20a1dd5..98d345d 100644 --- a/web/server/server.js +++ b/web/server/server.js @@ -51,6 +51,29 @@ if (lastUpdate > 0) { 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) { @@ -264,7 +287,11 @@ app.get('/api/recipes', (req, res) => { // Order an item from storage app.post('/api/order', (req, res) => { try { - const { itemName, amount, dropperName } = req.body; + 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' }); } @@ -272,14 +299,17 @@ app.post('/api/order', (req, res) => { const command = { type: 'command', action: 'order', + commandId, itemName, amount: parseInt(amount), 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({ success: true, message: `Order sent: ${itemName} x${amount}` }); + res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } @@ -315,9 +345,16 @@ app.post('/api/dropper-nicknames', (req, res) => { // Request inventory scan app.post('/api/scan', (req, res) => { try { - pushCommandToBridge({ type: 'command', action: 'scan' }); + 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({ success: true, message: 'Scan requested' }); + res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } @@ -326,9 +363,16 @@ app.post('/api/scan', (req, res) => { // Toggle smelting pause app.post('/api/smelting/toggle', (req, res) => { try { - pushCommandToBridge({ type: 'command', action: 'toggle_pause' }); + 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({ success: true }); + res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } @@ -337,12 +381,18 @@ app.post('/api/smelting/toggle', (req, res) => { // Toggle a specific recipe app.post('/api/recipes/toggle', (req, res) => { try { - const { recipe } = req.body; + const { recipe, commandId } = req.body; + + const cached = checkIdempotent(commandId); + if (cached) return res.json(cached); + if (!recipe) return res.status(400).json({ error: 'Missing recipe name' }); - pushCommandToBridge({ type: 'command', action: 'toggle_recipe', recipe }); + pushCommandToBridge({ type: 'command', action: 'toggle_recipe', commandId, recipe }); + const result = { success: true, commandId }; + recordCommand(commandId, result); console.log(`🔄 Recipe toggle: ${recipe}`); - res.json({ success: true }); + res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } @@ -350,24 +400,42 @@ app.post('/api/recipes/toggle', (req, res) => { // Enable/disable all recipes app.post('/api/recipes/enable-all', (req, res) => { - pushCommandToBridge({ type: 'command', action: 'enable_all' }); - res.json({ success: true }); + 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); }); app.post('/api/recipes/disable-all', (req, res) => { - pushCommandToBridge({ type: 'command', action: 'disable_all' }); - res.json({ success: true }); + 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); }); // Sort barrel app.post('/api/sort-barrel', (req, res) => { try { - const { barrelName } = req.body; + 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', barrelName }); + pushCommandToBridge({ type: 'command', action: 'sort_barrel', commandId, barrelName }); + const result = { success: true, commandId }; + recordCommand(commandId, result); console.log(`📦 Sort barrel: ${barrelName}`); - res.json({ success: true }); + res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } @@ -376,12 +444,18 @@ app.post('/api/sort-barrel', (req, res) => { // Craft item app.post('/api/craft', (req, res) => { try { - const { recipeIdx } = req.body; + 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' }); - pushCommandToBridge({ type: 'command', action: 'craft', recipeIdx: parseInt(recipeIdx) }); + pushCommandToBridge({ type: 'command', action: 'craft', commandId, recipeIdx: parseInt(recipeIdx) }); + const result = { success: true, commandId }; + recordCommand(commandId, result); console.log(`🔨 Craft request: recipe #${recipeIdx}`); - res.json({ success: true }); + res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } @@ -441,10 +515,11 @@ app.post('/api/bridge/commands/ack', (req, res) => { // Bridge sends command result app.post('/api/bridge/result', (req, res) => { try { - const { action, success, message, error: err } = req.body; + const { action, commandId, success, message, error: err } = req.body; broadcastToClients({ type: 'command_result', + commandId, action, success, message, @@ -617,9 +692,10 @@ wss.on('connection', (ws, req) => { // Full state update from bridge updateStateFromBridge(data); } else if (data.type === 'command_result') { - // Command result from bridge + // Command result from bridge — include commandId broadcastToClients({ type: 'command_result', + commandId: data.commandId, action: data.action, success: data.success, message: data.message, @@ -683,8 +759,17 @@ wss.on('connection', (ws, req) => { 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);