Implement idempotent command tracking with cleanup and caching for improved request handling

This commit is contained in:
MayaTheShy
2026-03-22 02:12:18 -04:00
parent 224738f2e7
commit bbae2740a7

View File

@@ -51,6 +51,29 @@ if (lastUpdate > 0) {
let pendingCommands = []; let pendingCommands = [];
let nextCommandId = 1; // Monotonic ID for deduplication 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 ========== // ========== Helpers ==========
function broadcastToClients(data) { function broadcastToClients(data) {
@@ -264,7 +287,11 @@ app.get('/api/recipes', (req, res) => {
// Order an item from storage // Order an item from storage
app.post('/api/order', (req, res) => { app.post('/api/order', (req, res) => {
try { 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) { if (!itemName || !amount) {
return res.status(400).json({ error: 'Missing itemName or amount' }); return res.status(400).json({ error: 'Missing itemName or amount' });
} }
@@ -272,14 +299,17 @@ app.post('/api/order', (req, res) => {
const command = { const command = {
type: 'command', type: 'command',
action: 'order', action: 'order',
commandId,
itemName, itemName,
amount: parseInt(amount), amount: parseInt(amount),
dropperName: dropperName || null, dropperName: dropperName || null,
}; };
pushCommandToBridge(command); pushCommandToBridge(command);
const result = { success: true, commandId, message: `Order sent: ${itemName} x${amount}` };
recordCommand(commandId, result);
console.log(`📦 Order: ${itemName} x${amount}`); console.log(`📦 Order: ${itemName} x${amount}`);
res.json({ success: true, message: `Order sent: ${itemName} x${amount}` }); res.json(result);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
@@ -315,9 +345,16 @@ app.post('/api/dropper-nicknames', (req, res) => {
// Request inventory scan // Request inventory scan
app.post('/api/scan', (req, res) => { app.post('/api/scan', (req, res) => {
try { 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'); console.log('🔍 Scan requested');
res.json({ success: true, message: 'Scan requested' }); res.json(result);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
@@ -326,9 +363,16 @@ app.post('/api/scan', (req, res) => {
// Toggle smelting pause // Toggle smelting pause
app.post('/api/smelting/toggle', (req, res) => { app.post('/api/smelting/toggle', (req, res) => {
try { 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'); console.log('🔥 Smelting toggle requested');
res.json({ success: true }); res.json(result);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
@@ -337,12 +381,18 @@ app.post('/api/smelting/toggle', (req, res) => {
// Toggle a specific recipe // Toggle a specific recipe
app.post('/api/recipes/toggle', (req, res) => { app.post('/api/recipes/toggle', (req, res) => {
try { 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' }); 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}`); console.log(`🔄 Recipe toggle: ${recipe}`);
res.json({ success: true }); res.json(result);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
@@ -350,24 +400,42 @@ app.post('/api/recipes/toggle', (req, res) => {
// Enable/disable all recipes // Enable/disable all recipes
app.post('/api/recipes/enable-all', (req, res) => { app.post('/api/recipes/enable-all', (req, res) => {
pushCommandToBridge({ type: 'command', action: 'enable_all' }); const { commandId } = req.body || {};
res.json({ success: true }); 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) => { app.post('/api/recipes/disable-all', (req, res) => {
pushCommandToBridge({ type: 'command', action: 'disable_all' }); const { commandId } = req.body || {};
res.json({ success: true }); 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 // Sort barrel
app.post('/api/sort-barrel', (req, res) => { app.post('/api/sort-barrel', (req, res) => {
try { 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' }); 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}`); console.log(`📦 Sort barrel: ${barrelName}`);
res.json({ success: true }); res.json(result);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
@@ -376,12 +444,18 @@ app.post('/api/sort-barrel', (req, res) => {
// Craft item // Craft item
app.post('/api/craft', (req, res) => { app.post('/api/craft', (req, res) => {
try { 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' }); 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}`); console.log(`🔨 Craft request: recipe #${recipeIdx}`);
res.json({ success: true }); res.json(result);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
@@ -441,10 +515,11 @@ app.post('/api/bridge/commands/ack', (req, res) => {
// Bridge sends command result // Bridge sends command result
app.post('/api/bridge/result', (req, res) => { app.post('/api/bridge/result', (req, res) => {
try { try {
const { action, success, message, error: err } = req.body; const { action, commandId, success, message, error: err } = req.body;
broadcastToClients({ broadcastToClients({
type: 'command_result', type: 'command_result',
commandId,
action, action,
success, success,
message, message,
@@ -617,9 +692,10 @@ wss.on('connection', (ws, req) => {
// Full state update from bridge // Full state update from bridge
updateStateFromBridge(data); updateStateFromBridge(data);
} else if (data.type === 'command_result') { } else if (data.type === 'command_result') {
// Command result from bridge // Command result from bridge — include commandId
broadcastToClients({ broadcastToClients({
type: 'command_result', type: 'command_result',
commandId: data.commandId,
action: data.action, action: data.action,
success: data.success, success: data.success,
message: data.message, message: data.message,
@@ -683,8 +759,17 @@ wss.on('connection', (ws, req) => {
const data = JSON.parse(message); const data = JSON.parse(message);
if (data.type === 'command') { 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 // Forward command to bridge
pushCommandToBridge(data); pushCommandToBridge(data);
if (data.commandId) {
recordCommand(data.commandId, { success: true, commandId: data.commandId });
}
} }
} catch (error) { } catch (error) {
console.error('❌ Error processing web client message:', error); console.error('❌ Error processing web client message:', error);