674 lines
22 KiB
JavaScript
674 lines
22 KiB
JavaScript
const path = require('path');
|
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
|
const Database = require('better-sqlite3');
|
|
const { config } = require('dotenv');
|
|
const bcrypt = require('bcryptjs');
|
|
const jwt = require('jsonwebtoken');
|
|
const { WebSocketServer } = require('ws');
|
|
|
|
// Load env vars
|
|
config();
|
|
|
|
// Database connection for Dev Server API
|
|
const dbPath = process.env.DB_PATH || path.resolve(__dirname, '../server/data/sensors.db');
|
|
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-key-change-me';
|
|
const WS_PORT = parseInt(process.env.WS_PORT || '3962', 10);
|
|
const DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT || '3905', 10);
|
|
const RULE_RUNNER_INTERVAL = parseInt(process.env.RULE_RUNNER_INTERVAL || '10000', 10);
|
|
let db;
|
|
|
|
try {
|
|
db = new Database(dbPath);
|
|
console.log(`[UI Server] Connected to database at ${dbPath}`);
|
|
|
|
// Create changelog table
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS changelog (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
date TEXT NOT NULL,
|
|
user TEXT,
|
|
text TEXT NOT NULL
|
|
)
|
|
`);
|
|
|
|
// Helper to insert changelog entry
|
|
global.insertChangelog = (user, text) => {
|
|
try {
|
|
if (!db) return;
|
|
const stmt = db.prepare('INSERT INTO changelog (date, user, text) VALUES (?, ?, ?)');
|
|
stmt.run(new Date().toISOString(), user || 'system', text);
|
|
console.log(`[Changelog] ${user || 'system'}: ${text}`);
|
|
} catch (err) {
|
|
console.error('[Changelog] Error inserting entry:', err.message);
|
|
}
|
|
};
|
|
|
|
} catch (err) {
|
|
console.error(`[UI Server] Failed to connect to database at ${dbPath}:`, err.message);
|
|
}
|
|
|
|
// Output bindings: map virtual outputs to physical devices
|
|
// Format: outputChannel -> { device, channel, type }
|
|
const OUTPUT_BINDINGS = {
|
|
'BigDehumid': { device: 'tapo', channel: 'r0', type: 'switch' },
|
|
'CO2Valve': { device: 'tapo', channel: 'c', type: 'switch' },
|
|
'TentExhaust': { device: 'tapo', channel: 'fantent', type: 'switch' },
|
|
'CircFanLevel': { device: 'ac', channel: 'tent:fan', type: 'level' },
|
|
'RoomExhaust': { device: 'ac', channel: 'wall-fan', type: 'level' },
|
|
};
|
|
|
|
// =============================================
|
|
// WebSocket Server for Agents (port 3962)
|
|
// =============================================
|
|
|
|
// Track authenticated clients by devicePrefix
|
|
const agentClients = new Map(); // devicePrefix -> Set<ws>
|
|
|
|
function validateApiKey(apiKey) {
|
|
if (!db) return null;
|
|
try {
|
|
const stmt = db.prepare('SELECT id, name, device_prefix FROM api_keys WHERE key = ?');
|
|
const result = stmt.get(apiKey);
|
|
|
|
if (result) {
|
|
// Update last_used_at timestamp
|
|
db.prepare("UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?").run(result.id);
|
|
}
|
|
|
|
return result || null;
|
|
} catch (err) {
|
|
console.error('[WS] Error validating API key:', err.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function insertReadingsSmart(devicePrefix, readings) {
|
|
if (!db) throw new Error('Database not connected');
|
|
|
|
const isoTimestamp = new Date().toISOString();
|
|
|
|
const stmtLast = db.prepare(`
|
|
SELECT id, value, data, data_type
|
|
FROM sensor_events
|
|
WHERE device = ? AND channel = ?
|
|
ORDER BY timestamp DESC
|
|
LIMIT 1
|
|
`);
|
|
|
|
const stmtUpdate = db.prepare(`
|
|
UPDATE sensor_events SET until = ? WHERE id = ?
|
|
`);
|
|
|
|
const stmtInsert = db.prepare(`
|
|
INSERT INTO sensor_events (timestamp, until, device, channel, value, data, data_type)
|
|
VALUES (?, NULL, ?, ?, ?, ?, ?)
|
|
`);
|
|
|
|
const transaction = db.transaction((items) => {
|
|
let inserted = 0;
|
|
let updated = 0;
|
|
|
|
for (const reading of items) {
|
|
const fullDevice = `${devicePrefix}${reading.device}`;
|
|
const channel = reading.channel;
|
|
|
|
// Determine type and values
|
|
let dataType = 'number';
|
|
let value = null;
|
|
let data = null;
|
|
|
|
if (reading.value !== undefined && reading.value !== null) {
|
|
dataType = 'number';
|
|
value = reading.value;
|
|
} else if (reading.data !== undefined) {
|
|
dataType = 'json';
|
|
data = typeof reading.data === 'string' ? reading.data : JSON.stringify(reading.data);
|
|
} else {
|
|
continue; // Skip invalid
|
|
}
|
|
|
|
// Check last reading for RLE
|
|
const last = stmtLast.get(fullDevice, channel);
|
|
let isDuplicate = false;
|
|
|
|
if (last && last.data_type === dataType) {
|
|
if (dataType === 'number') {
|
|
if (Math.abs(last.value - value) < Number.EPSILON) {
|
|
isDuplicate = true;
|
|
}
|
|
} else {
|
|
// Compare JSON strings
|
|
if (last.data === data) {
|
|
isDuplicate = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isDuplicate) {
|
|
stmtUpdate.run(isoTimestamp, last.id);
|
|
updated++;
|
|
} else {
|
|
stmtInsert.run(isoTimestamp, fullDevice, channel, value, data, dataType);
|
|
inserted++;
|
|
}
|
|
}
|
|
return { inserted, updated };
|
|
});
|
|
|
|
return transaction(readings);
|
|
}
|
|
|
|
function createAgentWebSocketServer() {
|
|
const wss = new WebSocketServer({ port: WS_PORT });
|
|
|
|
wss.on('connection', (ws, req) => {
|
|
const clientId = `${req.socket.remoteAddress}:${req.socket.remotePort}`;
|
|
console.log(`[WS] Client connected: ${clientId}`);
|
|
|
|
const clientState = {
|
|
authenticated: false,
|
|
devicePrefix: null,
|
|
name: null,
|
|
lastPong: Date.now()
|
|
};
|
|
|
|
// Set up ping/pong for keepalive
|
|
ws.isAlive = true;
|
|
ws.on('pong', () => {
|
|
ws.isAlive = true;
|
|
clientState.lastPong = Date.now();
|
|
});
|
|
|
|
ws.on('message', (data) => {
|
|
try {
|
|
const message = JSON.parse(data.toString());
|
|
handleAgentMessage(ws, message, clientState, clientId);
|
|
} catch (err) {
|
|
console.error(`[WS] Error parsing message from ${clientId}:`, err.message);
|
|
ws.send(JSON.stringify({ type: 'error', error: 'Invalid JSON' }));
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
console.log(`[WS] Client disconnected: ${clientId} (${clientState.name || 'unauthenticated'})`);
|
|
if (clientState.devicePrefix && agentClients.has(clientState.devicePrefix)) {
|
|
agentClients.get(clientState.devicePrefix).delete(ws);
|
|
}
|
|
});
|
|
|
|
ws.on('error', (err) => {
|
|
console.error(`[WS] Error for ${clientId}:`, err.message);
|
|
});
|
|
});
|
|
|
|
// Ping interval to detect dead connections
|
|
const pingInterval = setInterval(() => {
|
|
wss.clients.forEach((ws) => {
|
|
if (ws.isAlive === false) {
|
|
console.log('[WS] Terminating unresponsive client');
|
|
return ws.terminate();
|
|
}
|
|
ws.isAlive = false;
|
|
ws.ping();
|
|
});
|
|
}, 30000);
|
|
|
|
wss.on('close', () => {
|
|
clearInterval(pingInterval);
|
|
});
|
|
|
|
console.log(`[WS] WebSocket server listening on port ${WS_PORT}`);
|
|
return wss;
|
|
}
|
|
|
|
function handleAgentMessage(ws, message, clientState, clientId) {
|
|
const { type } = message;
|
|
|
|
switch (type) {
|
|
case 'auth':
|
|
const { apiKey } = message;
|
|
if (!apiKey) {
|
|
ws.send(JSON.stringify({ type: 'auth', success: false, error: 'Missing apiKey' }));
|
|
return;
|
|
}
|
|
|
|
const keyInfo = validateApiKey(apiKey);
|
|
if (!keyInfo) {
|
|
ws.send(JSON.stringify({ type: 'auth', success: false, error: 'Invalid API key' }));
|
|
return;
|
|
}
|
|
|
|
clientState.authenticated = true;
|
|
clientState.devicePrefix = keyInfo.device_prefix;
|
|
clientState.name = keyInfo.name;
|
|
|
|
// Track this connection
|
|
if (!agentClients.has(keyInfo.device_prefix)) {
|
|
agentClients.set(keyInfo.device_prefix, new Set());
|
|
}
|
|
agentClients.get(keyInfo.device_prefix).add(ws);
|
|
|
|
console.log(`[WS] Client authenticated: ${keyInfo.name} (prefix: ${keyInfo.device_prefix})`);
|
|
ws.send(JSON.stringify({ type: 'auth', success: true, devicePrefix: keyInfo.device_prefix, name: keyInfo.name }));
|
|
break;
|
|
|
|
case 'pong':
|
|
// Keepalive from agent - just update timestamp
|
|
clientState.lastPong = Date.now();
|
|
break;
|
|
|
|
case 'data':
|
|
if (!clientState.authenticated) {
|
|
ws.send(JSON.stringify({ type: 'error', error: 'Not authenticated' }));
|
|
return;
|
|
}
|
|
|
|
const { readings } = message;
|
|
if (!Array.isArray(readings) || readings.length === 0) {
|
|
ws.send(JSON.stringify({ type: 'error', error: 'Invalid readings' }));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const validReadings = readings.filter(r => r.device && r.channel && (r.value !== undefined || r.data !== undefined));
|
|
const result = insertReadingsSmart(clientState.devicePrefix, validReadings);
|
|
|
|
// Trigger rules immediately on new data
|
|
if (runRules) runRules();
|
|
|
|
ws.send(JSON.stringify({ type: 'ack', count: result.inserted + result.updated }));
|
|
} catch (err) {
|
|
console.error('[WS] Error inserting readings:', err.message);
|
|
ws.send(JSON.stringify({ type: 'error', error: 'Failed to insert readings' }));
|
|
}
|
|
break;
|
|
|
|
default:
|
|
ws.send(JSON.stringify({ type: 'error', error: `Unknown message type: ${type}` }));
|
|
}
|
|
}
|
|
|
|
// Send command to all agents with the given device prefix
|
|
function sendCommandToDevicePrefix(devicePrefix, command) {
|
|
const clients = agentClients.get(devicePrefix);
|
|
if (!clients || clients.size === 0) {
|
|
console.log(`[WS] No connected agents for prefix: ${devicePrefix}`);
|
|
return false;
|
|
}
|
|
|
|
const message = JSON.stringify({ type: 'command', ...command });
|
|
let sent = 0;
|
|
|
|
for (const ws of clients) {
|
|
if (ws.readyState === 1) { // OPEN
|
|
ws.send(message);
|
|
sent++;
|
|
}
|
|
}
|
|
|
|
console.log(`[WS] Sent command to ${sent} agent(s) with prefix ${devicePrefix}:`, command);
|
|
return sent > 0;
|
|
}
|
|
|
|
// Periodic sync: push non-zero output states to agents every 60s
|
|
function syncOutputStates() {
|
|
if (!db) return;
|
|
|
|
try {
|
|
// Get current output values
|
|
const stmt = db.prepare(`
|
|
SELECT channel, value FROM output_events
|
|
WHERE id IN (SELECT MAX(id) FROM output_events GROUP BY channel)
|
|
`);
|
|
const rows = stmt.all();
|
|
|
|
for (const row of rows) {
|
|
// Only sync non-zero values
|
|
if (row.value > 0) {
|
|
const binding = OUTPUT_BINDINGS[row.channel];
|
|
if (binding) {
|
|
let commandValue = row.value;
|
|
if (binding.type === 'switch') {
|
|
commandValue = row.value > 0 ? 1 : 0;
|
|
}
|
|
|
|
const success = sendCommandToDevicePrefix(`${binding.device}:`, {
|
|
device: binding.channel,
|
|
action: 'set_state',
|
|
value: commandValue
|
|
});
|
|
|
|
if (!success) {
|
|
console.error(`[Sync] ERROR: Cannot deliver 'on' command for ${row.channel} -> ${binding.device}:${binding.channel} (no agent connected)`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('[Sync] Error syncing output states:', err.message);
|
|
}
|
|
}
|
|
|
|
// Start output state sync interval (every 60s)
|
|
setInterval(syncOutputStates, 60000);
|
|
|
|
// =============================================
|
|
// RULE ENGINE (Global Scope)
|
|
// =============================================
|
|
|
|
// Virtual output channel definitions
|
|
const OUTPUT_CHANNELS = [
|
|
{ channel: 'CircFanLevel', type: 'number', min: 0, max: 10, description: 'Circulation Fan Level' },
|
|
{ channel: 'CO2Valve', type: 'boolean', min: 0, max: 1, description: 'CO2 Valve' },
|
|
{ channel: 'BigDehumid', type: 'boolean', min: 0, max: 1, description: 'Big Dehumidifier' },
|
|
{ channel: 'TentExhaust', type: 'boolean', min: 0, max: 1, description: 'Tent Exhaust Fan' },
|
|
{ channel: 'RoomExhaust', type: 'number', min: 0, max: 10, description: 'Room Exhaust Fan' },
|
|
];
|
|
|
|
// Get current sensor value
|
|
function getSensorValue(channel) {
|
|
// channel format: "device:channel" e.g. "ac:controller:co2"
|
|
const lastColonIndex = channel.lastIndexOf(':');
|
|
if (lastColonIndex === -1) return null;
|
|
const device = channel.substring(0, lastColonIndex);
|
|
const ch = channel.substring(lastColonIndex + 1);
|
|
|
|
const stmt = db.prepare(`
|
|
SELECT value FROM sensor_events
|
|
WHERE device = ? AND channel = ?
|
|
ORDER BY timestamp DESC LIMIT 1
|
|
`);
|
|
const row = stmt.get(device, ch);
|
|
return row ? row.value : null;
|
|
}
|
|
|
|
// Get current output value
|
|
function getOutputValue(channel) {
|
|
const stmt = db.prepare(`
|
|
SELECT value FROM output_events
|
|
WHERE channel = ?
|
|
ORDER BY timestamp DESC LIMIT 1
|
|
`);
|
|
const row = stmt.get(channel);
|
|
return row ? row.value : 0;
|
|
}
|
|
|
|
// Write output value with RLE
|
|
function writeOutputValue(channel, value) {
|
|
const now = new Date().toISOString();
|
|
|
|
const lastStmt = db.prepare(`
|
|
SELECT id, value FROM output_events
|
|
WHERE channel = ?
|
|
ORDER BY timestamp DESC LIMIT 1
|
|
`);
|
|
const last = lastStmt.get(channel);
|
|
|
|
const valueChanged = !last || Math.abs(last.value - value) >= Number.EPSILON;
|
|
|
|
if (!valueChanged) {
|
|
// Same value - update the until timestamp (RLE)
|
|
const updateStmt = db.prepare('UPDATE output_events SET until = ? WHERE id = ?');
|
|
updateStmt.run(now, last.id);
|
|
} else {
|
|
// New value - insert new record
|
|
const insertStmt = db.prepare(`
|
|
INSERT INTO output_events (timestamp, until, channel, value, data_type)
|
|
VALUES (?, NULL, ?, ?, 'number')
|
|
`);
|
|
insertStmt.run(now, channel, value);
|
|
console.log(`[RuleRunner] Output changed: ${channel} = ${value}`);
|
|
|
|
// Send command to bound physical device
|
|
const binding = OUTPUT_BINDINGS[channel];
|
|
if (binding) {
|
|
let commandValue = value;
|
|
if (binding.type === 'switch') {
|
|
commandValue = value > 0 ? 1 : 0;
|
|
}
|
|
|
|
console.log(`[RuleRunner] Binding for ${channel}: type=${binding.type}, val=${value}, cmdVal=${commandValue}`);
|
|
|
|
sendCommandToDevicePrefix(`${binding.device}:`, {
|
|
device: binding.channel,
|
|
action: 'set_state',
|
|
value: commandValue
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compare values with operator
|
|
function compareValues(actual, operator, target) {
|
|
if (actual === null || actual === undefined) return false;
|
|
switch (operator) {
|
|
case '=':
|
|
case '==': return actual === target;
|
|
case '!=': return actual !== target;
|
|
case '<': return actual < target;
|
|
case '>': return actual > target;
|
|
case '<=': return actual <= target;
|
|
case '>=': return actual >= target;
|
|
default: return false;
|
|
}
|
|
}
|
|
|
|
// Evaluate a single condition
|
|
function evaluateCondition(condition) {
|
|
const { type, operator, value, channel } = condition;
|
|
|
|
// Handle AND/OR groups
|
|
if (operator === 'AND' || operator === 'OR') {
|
|
const results = (condition.conditions || []).map(c => evaluateCondition(c));
|
|
return operator === 'AND'
|
|
? results.every(r => r)
|
|
: results.some(r => r);
|
|
}
|
|
|
|
switch (type) {
|
|
case 'time': {
|
|
const now = new Date();
|
|
const currentTime = now.getHours() * 60 + now.getMinutes(); // minutes since midnight
|
|
|
|
if (operator === 'between' && Array.isArray(value)) {
|
|
const [start, end] = value.map(t => {
|
|
const [h, m] = t.split(':').map(Number);
|
|
return h * 60 + m;
|
|
});
|
|
return currentTime >= start && currentTime <= end;
|
|
}
|
|
|
|
const [h, m] = String(value).split(':').map(Number);
|
|
const targetTime = h * 60 + m;
|
|
return compareValues(currentTime, operator, targetTime);
|
|
}
|
|
|
|
case 'date': {
|
|
const now = new Date();
|
|
const today = now.toISOString().split('T')[0];
|
|
|
|
if (operator === 'between' && Array.isArray(value)) {
|
|
return today >= value[0] && today <= value[1];
|
|
}
|
|
if (operator === 'before') return today < value;
|
|
if (operator === 'after') return today > value;
|
|
return today === value;
|
|
}
|
|
|
|
case 'sensor': {
|
|
const sensorValue = getSensorValue(channel);
|
|
let target = value;
|
|
|
|
if (value && typeof value === 'object' && value.type === 'dynamic') {
|
|
const targetSensorVal = getSensorValue(value.channel) || 0;
|
|
target = (targetSensorVal * (value.factor || 1)) + (value.offset || 0);
|
|
}
|
|
|
|
return compareValues(sensorValue, operator, target);
|
|
}
|
|
|
|
case 'output': {
|
|
const outputValue = getOutputValue(channel);
|
|
return compareValues(outputValue, operator, value);
|
|
}
|
|
|
|
default:
|
|
console.warn(`[RuleRunner] Unknown condition type: ${type}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Global set to track currently active rule IDs
|
|
const activeRuleIds = new Set();
|
|
|
|
// Run all rules
|
|
function runRules() {
|
|
if (!db) return;
|
|
|
|
try {
|
|
const rules = db.prepare('SELECT * FROM rules WHERE enabled = 1 ORDER BY position ASC').all();
|
|
|
|
// Clear active rules list at start of run
|
|
activeRuleIds.clear();
|
|
|
|
// Default all outputs to OFF (0) - if no rule sets them, they stay off
|
|
const desiredOutputs = {};
|
|
for (const ch of OUTPUT_CHANNELS) {
|
|
desiredOutputs[ch.channel] = 0;
|
|
}
|
|
|
|
for (const rule of rules) {
|
|
try {
|
|
const conditions = JSON.parse(rule.conditions || '{}');
|
|
const action = JSON.parse(rule.action || '{}');
|
|
|
|
if (evaluateCondition(conditions)) {
|
|
// Rule matches - add to active list
|
|
activeRuleIds.add(rule.id);
|
|
|
|
// Rule matches - set output (later rules override)
|
|
if (action.channel && action.value !== undefined) {
|
|
let finalValue = action.value;
|
|
|
|
// Handle calculated value
|
|
if (action.value && typeof action.value === 'object' && action.value.type === 'calculated') {
|
|
const valA = getSensorValue(action.value.sensorA) || 0;
|
|
const valB = action.value.sensorB ? (getSensorValue(action.value.sensorB) || 0) : 0;
|
|
const diff = valA - valB;
|
|
finalValue = (diff * (action.value.factor || 1)) + (action.value.offset || 0);
|
|
}
|
|
|
|
desiredOutputs[action.channel] = finalValue;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error(`[RuleRunner] Error evaluating rule ${rule.id}:`, err.message);
|
|
}
|
|
}
|
|
|
|
// Write output values
|
|
for (const [channel, value] of Object.entries(desiredOutputs)) {
|
|
writeOutputValue(channel, value);
|
|
}
|
|
} catch (err) {
|
|
console.error('[RuleRunner] Error running rules:', err.message);
|
|
}
|
|
}
|
|
|
|
// Also sync immediately on startup after a short delay
|
|
setTimeout(syncOutputStates, 5000);
|
|
|
|
// Start the WebSocket server
|
|
const agentWss = createAgentWebSocketServer();
|
|
|
|
// Import API setup
|
|
const setupAllApis = require('./api');
|
|
|
|
module.exports = {
|
|
entry: './src/index.js',
|
|
output: {
|
|
path: path.join(__dirname, 'dist'),
|
|
filename: 'bundle.js',
|
|
clean: true,
|
|
},
|
|
mode: 'development',
|
|
devtool: 'source-map',
|
|
module: {
|
|
rules: [
|
|
{
|
|
test: /\.(js|jsx)$/,
|
|
exclude: /node_modules/,
|
|
use: {
|
|
loader: 'babel-loader',
|
|
options: {
|
|
presets: ['@babel/preset-env', '@babel/preset-react'],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
test: /\.css$/,
|
|
use: ['style-loader', 'css-loader'],
|
|
},
|
|
{
|
|
// Fix for ESM modules in node_modules (MUI X Charts v8)
|
|
test: /\.m?js$/,
|
|
resolve: {
|
|
fullySpecified: false,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
resolve: {
|
|
extensions: ['.js', '.jsx'],
|
|
},
|
|
plugins: [
|
|
new HtmlWebpackPlugin({
|
|
template: './public/index.html',
|
|
}),
|
|
],
|
|
devServer: {
|
|
port: DEV_SERVER_PORT,
|
|
historyApiFallback: true,
|
|
hot: true,
|
|
allowedHosts: 'all',
|
|
client: {
|
|
webSocketURL: 'auto://0.0.0.0:0/ws',
|
|
progress: true,
|
|
},
|
|
setupMiddlewares: (middlewares, devServer) => {
|
|
if (!devServer) {
|
|
throw new Error('webpack-dev-server is not defined');
|
|
}
|
|
|
|
// Setup body parser
|
|
const app = devServer.app;
|
|
const bodyParser = require('body-parser');
|
|
app.use(bodyParser.json());
|
|
|
|
// Setup all API routes from extracted modules
|
|
setupAllApis(app, {
|
|
db,
|
|
bcrypt,
|
|
jwt,
|
|
JWT_SECRET,
|
|
OUTPUT_CHANNELS,
|
|
OUTPUT_BINDINGS,
|
|
runRules,
|
|
activeRuleIds
|
|
});
|
|
|
|
// Start rule runner
|
|
const ruleRunnerInterval = setInterval(runRules, RULE_RUNNER_INTERVAL);
|
|
console.log(`[RuleRunner] Started background job (${RULE_RUNNER_INTERVAL / 1000}s interval)`);
|
|
|
|
// Clean up on server close
|
|
devServer.server?.on('close', () => {
|
|
clearInterval(ruleRunnerInterval);
|
|
console.log('[RuleRunner] Stopped background job');
|
|
});
|
|
|
|
return middlewares;
|
|
},
|
|
},
|
|
};
|