683 lines
20 KiB
JavaScript
683 lines
20 KiB
JavaScript
import express from 'express';
|
|
import { WebSocketServer } from 'ws';
|
|
import cors from 'cors';
|
|
import { createServer } from 'http';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import {
|
|
loadFullState, saveFullState, recordItemHistory,
|
|
saveItems, saveFurnaces, saveAlerts, saveState,
|
|
getHistory, getHistorySummary, closeDb, flushPendingSave,
|
|
} from './db.js';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3001;
|
|
const HOST = process.env.HOST || '0.0.0.0';
|
|
|
|
app.use(cors());
|
|
app.use(express.json({ limit: '5mb' }));
|
|
|
|
// ========== State ==========
|
|
const webClients = new Set();
|
|
const bridgeClients = new Set();
|
|
|
|
// 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;
|
|
|
|
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 = [];
|
|
|
|
// ========== Helpers ==========
|
|
|
|
function broadcastToClients(data) {
|
|
const message = JSON.stringify(data);
|
|
webClients.forEach((client) => {
|
|
if (client.readyState === 1) {
|
|
client.send(message);
|
|
}
|
|
});
|
|
}
|
|
|
|
function pushCommandToBridge(command) {
|
|
let sent = false;
|
|
for (const bridge of bridgeClients) {
|
|
if (bridge.readyState === 1) {
|
|
bridge.send(JSON.stringify(command));
|
|
sent = true;
|
|
}
|
|
}
|
|
if (!sent) {
|
|
// Fallback: queue for HTTP polling
|
|
pendingCommands.push({ ...command, timestamp: Date.now() });
|
|
console.log(`[Bridge] Queued command via HTTP poll (no WS bridge)`);
|
|
}
|
|
}
|
|
|
|
// ========== HTTP Server ==========
|
|
const server = createServer(app);
|
|
|
|
// ========== 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
|
|
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',
|
|
};
|
|
|
|
// Ensure cache directory exists
|
|
fs.mkdirSync(TEXTURE_CACHE_DIR, { recursive: true });
|
|
|
|
app.get('/api/texture/:namespace/*', async (req, res) => {
|
|
const { namespace } = req.params;
|
|
const texturePath = req.params[0]; // e.g. "item/diamond.png"
|
|
|
|
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 check
|
|
app.get('/api/health', (req, res) => {
|
|
res.json({
|
|
status: 'ok',
|
|
lastUpdate,
|
|
uptime: process.uptime(),
|
|
bridgeConnected: bridgeClients.size > 0,
|
|
webClients: webClients.size,
|
|
});
|
|
});
|
|
|
|
// 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,
|
|
});
|
|
});
|
|
|
|
// 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 } = req.body;
|
|
if (!itemName || !amount) {
|
|
return res.status(400).json({ error: 'Missing itemName or amount' });
|
|
}
|
|
|
|
const command = {
|
|
type: 'command',
|
|
action: 'order',
|
|
itemName,
|
|
amount: parseInt(amount),
|
|
dropperName: dropperName || null,
|
|
};
|
|
|
|
pushCommandToBridge(command);
|
|
console.log(`📦 Order: ${itemName} x${amount}`);
|
|
res.json({ success: true, message: `Order sent: ${itemName} x${amount}` });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Request inventory scan
|
|
app.post('/api/scan', (req, res) => {
|
|
try {
|
|
pushCommandToBridge({ type: 'command', action: 'scan' });
|
|
console.log('🔍 Scan requested');
|
|
res.json({ success: true, message: 'Scan requested' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Toggle smelting pause
|
|
app.post('/api/smelting/toggle', (req, res) => {
|
|
try {
|
|
pushCommandToBridge({ type: 'command', action: 'toggle_pause' });
|
|
console.log('🔥 Smelting toggle requested');
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Toggle a specific recipe
|
|
app.post('/api/recipes/toggle', (req, res) => {
|
|
try {
|
|
const { recipe } = req.body;
|
|
if (!recipe) return res.status(400).json({ error: 'Missing recipe name' });
|
|
|
|
pushCommandToBridge({ type: 'command', action: 'toggle_recipe', recipe });
|
|
console.log(`🔄 Recipe toggle: ${recipe}`);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Enable/disable all recipes
|
|
app.post('/api/recipes/enable-all', (req, res) => {
|
|
pushCommandToBridge({ type: 'command', action: 'enable_all' });
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.post('/api/recipes/disable-all', (req, res) => {
|
|
pushCommandToBridge({ type: 'command', action: 'disable_all' });
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// Sort barrel
|
|
app.post('/api/sort-barrel', (req, res) => {
|
|
try {
|
|
const { barrelName } = req.body;
|
|
if (!barrelName) return res.status(400).json({ error: 'Missing barrelName' });
|
|
|
|
pushCommandToBridge({ type: 'command', action: 'sort_barrel', barrelName });
|
|
console.log(`📦 Sort barrel: ${barrelName}`);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Craft item
|
|
app.post('/api/craft', (req, res) => {
|
|
try {
|
|
const { recipeIdx } = req.body;
|
|
if (recipeIdx === undefined) return res.status(400).json({ error: 'Missing recipeIdx' });
|
|
|
|
pushCommandToBridge({ type: 'command', action: 'craft', recipeIdx: parseInt(recipeIdx) });
|
|
console.log(`🔨 Craft request: recipe #${recipeIdx}`);
|
|
res.json({ success: true });
|
|
} 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
|
|
app.get('/api/bridge/commands', (req, res) => {
|
|
try {
|
|
const now = Date.now();
|
|
// Clear old commands (>30s)
|
|
pendingCommands = pendingCommands.filter(cmd => (now - cmd.timestamp) < 30000);
|
|
|
|
const commands = [...pendingCommands];
|
|
res.json({ commands });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Bridge acknowledges commands
|
|
app.post('/api/bridge/commands/ack', (req, res) => {
|
|
try {
|
|
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, success, message, error: err } = req.body;
|
|
|
|
broadcastToClients({
|
|
type: 'command_result',
|
|
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,
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ========== WebSocket Server ==========
|
|
|
|
const wss = new WebSocketServer({ server });
|
|
|
|
console.log(`🚀 Inventory Manager Web Server starting...`);
|
|
console.log(`📡 HTTP Server: http://localhost:${PORT}`);
|
|
console.log(`🔌 WebSocket Server: ws://localhost:${PORT}/ws`);
|
|
|
|
wss.on('connection', (ws, req) => {
|
|
const url = req.url || '';
|
|
|
|
// ---- Bridge WebSocket connection ----
|
|
if (url.startsWith('/ws/bridge')) {
|
|
console.log('🌉 CC:Tweaked bridge connected via WebSocket');
|
|
bridgeClients.add(ws);
|
|
|
|
ws.on('message', (raw) => {
|
|
try {
|
|
const data = JSON.parse(raw);
|
|
|
|
if (data.type === 'ping') {
|
|
ws.send(JSON.stringify({ type: 'pong' }));
|
|
return;
|
|
}
|
|
|
|
if (data.type === 'state') {
|
|
// Full state update from bridge
|
|
updateStateFromBridge(data);
|
|
} else if (data.type === 'command_result') {
|
|
// Command result from bridge
|
|
broadcastToClients({
|
|
type: 'command_result',
|
|
action: data.action,
|
|
success: data.success,
|
|
message: data.message,
|
|
error: data.error,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Bridge WS message error:', error.message);
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
console.log('🌉 CC:Tweaked bridge disconnected');
|
|
bridgeClients.delete(ws);
|
|
});
|
|
|
|
ws.on('error', (error) => {
|
|
console.error('❌ Bridge WS error:', error);
|
|
bridgeClients.delete(ws);
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
// ---- Web client WebSocket connection ----
|
|
console.log('🌐 New web client connected');
|
|
webClients.add(ws);
|
|
ws.isAlive = true;
|
|
|
|
// Send current state to new client
|
|
ws.send(JSON.stringify({
|
|
type: 'initial_state',
|
|
inventory: inventoryState,
|
|
activity: activityState,
|
|
alerts: alertsState,
|
|
smeltingPaused,
|
|
disabledRecipes,
|
|
smeltable: smeltableRecipes,
|
|
craftable: craftableRecipes,
|
|
craftTurtleOk,
|
|
lastUpdate,
|
|
bridgeConnected: bridgeClients.size > 0,
|
|
}));
|
|
|
|
ws.on('pong', () => { ws.isAlive = true; });
|
|
|
|
ws.on('message', (message) => {
|
|
try {
|
|
const data = JSON.parse(message);
|
|
|
|
if (data.type === 'command') {
|
|
// Forward command to bridge
|
|
pushCommandToBridge(data);
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Error processing web client message:', error);
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
console.log('👋 Web client disconnected');
|
|
webClients.delete(ws);
|
|
});
|
|
|
|
ws.on('error', (error) => {
|
|
console.error('❌ WebSocket error:', error);
|
|
});
|
|
});
|
|
|
|
// ========== WebSocket Keep-Alive ==========
|
|
// Ping all web clients every 25s to keep connections alive through reverse proxies
|
|
const WS_PING_INTERVAL = setInterval(() => {
|
|
webClients.forEach((ws) => {
|
|
if (!ws.isAlive) {
|
|
webClients.delete(ws);
|
|
return ws.terminate();
|
|
}
|
|
ws.isAlive = false;
|
|
ws.ping();
|
|
});
|
|
}, 25000);
|
|
|
|
wss.on('close', () => {
|
|
clearInterval(WS_PING_INTERVAL);
|
|
});
|
|
|
|
// ========== Start Server ==========
|
|
|
|
server.listen(PORT, HOST, () => {
|
|
console.log(`✅ Inventory Manager Web Server ready on ${HOST}:${PORT}`);
|
|
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`);
|
|
});
|
|
|
|
// Graceful shutdown
|
|
function shutdown() {
|
|
console.log('\n🛑 Shutting down server...');
|
|
try {
|
|
closeDb();
|
|
console.log('💾 Database closed');
|
|
} catch (err) {
|
|
console.error('❌ Error closing database:', err.message);
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
process.on('SIGINT', shutdown);
|
|
process.on('SIGTERM', shutdown);
|