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 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);