Implement idempotent command tracking with cleanup and caching for improved request handling
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user