@frangoteam/fuxa
Version:
Web-based Process Visualization (SCADA/HMI/Dashboard) software
287 lines (262 loc) • 12.3 kB
JavaScript
// server/integrations/node-red/index.js
// Optional Node-RED integration: mount only if the package is installed.
const fs = require('fs');
const path = require('path');
function tryRequireNodeRed() {
try {
// Fast check whether the module is resolvable
require.resolve('node-red');
// Load it only after resolve succeeds
// (keeps main process clean when not installed)
// eslint-disable-next-line global-require
return require('node-red');
} catch {
return null;
}
}
async function mountNodeRedIfInstalled({ app, server, settings, runtime, logger, authJwt, events }) {
const RED = tryRequireNodeRed();
if (!RED) {
logger.info('[Node-RED] Package not installed. Skipping Node-RED initialization.');
// Do not register any /nodered routes; default 404 is fine.
return;
}
// Prepare Node-RED userDir for flows and settings
const userDir = path.join(settings.workDir, 'node-red');
if (!fs.existsSync(userDir)) {
fs.mkdirSync(userDir, { recursive: true });
}
// Minimal Node-RED settings; extend only what is really needed
const redSettings = {
httpAdminRoot: '/nodered/',
// Serve Node-RED HTTP nodes under /dashboard to avoid intercepting FUXA routes
httpNodeRoot: '/dashboard',
userDir,
nodesDir: [path.join(__dirname, 'node-red-contrib-fuxa')],
flowFile: 'flows.json',
editorTheme: {
notifications: { enabled: false },
tours: { enabled: false },
},
// Dashboard will be exposed at /dashboard/...
ui: { path: '/' },
// Values used by FlowFuse dashboard's ui_base.js for layout saves
// These mirror the FUXA HTTP bind address so Node-RED can call its own /nodered/flows API
uiHost: settings.uiHost,
uiPort: settings.uiPort,
functionGlobalContext: {
// Expose essential FUXA runtime helpers
fuxa: {
runtime,
getTag: require(path.join(settings.appDir, 'runtime/devices')).getTagValue,
setTag: require(path.join(settings.appDir, 'runtime/devices')).setTagValue,
getDaq: require(path.join(settings.appDir, 'runtime/storage/daqstorage')).getNodeValues,
getTagId: require(path.join(settings.appDir, 'runtime/devices')).getTagId,
getHistoricalTags: require(path.join(settings.appDir, 'runtime/devices')).getHistoricalTags,
emit: events.emit.bind(events),
on: events.on.bind(events),
removeListener: events.removeListener.bind(events),
sendMessage: async (address, subject, message) =>
await runtime.notificatorMgr.sendMailMessage(null, address, subject, message, null, null),
getAlarms: async () => await runtime.alarmsMgr.getAlarmsValues(null, -1),
getHistoryAlarms: async (start, end) => {
const query = { start, end };
return runtime.alarmsMgr.getAlarmsHistory(query, -1);
},
ackAlarm: async (alarmName, types) => {
const utils = require(path.join(settings.appDir, 'runtime/utils'));
const sep = runtime.alarmsMgr.getIdSeparator();
if (alarmName.indexOf(sep) === -1 && !utils.isNullOrUndefined(types)) {
const results = [];
for (const t of types) {
const alarmId = `${alarmName}${sep}${t}`;
results.push(await runtime.alarmsMgr.setAlarmAck(alarmId, null, -1));
}
return results;
}
return runtime.alarmsMgr.setAlarmAck(alarmName, null, -1);
},
getScripts: async () => {
const scripts = await runtime.project.getScripts();
return scripts ? scripts.map(s => ({ id: s.id, name: s.name })) : [];
},
runScript: async (scriptName, params) => {
const scripts = await runtime.project.getScripts();
const script = scripts?.find(s => s.name === scriptName);
if (!script) throw new Error(`Script '${scriptName}' not found`);
return runtime.scriptsMgr.runScript(script, null, params);
},
},
// Common Node.js stdlib modules handy in flows
fs: require('fs').promises,
fsSync: require('fs'),
path: require('path'),
util: require('util'),
os: require('os'),
http: require('http'),
https: require('https'),
dgram: require('dgram'),
dns: require('dns'),
url: require('url'),
querystring: require('querystring'),
crypto: require('crypto'),
zlib: require('zlib'),
stream: require('stream'),
events: require('events'),
buffer: require('buffer'),
sqlite3: require('sqlite3'),
serialport: require('serialport'),
// Dangerous modules are opt-in only (see settings.nodeRedUnsafeModules)
...(settings.nodeRedUnsafeModules ? {
child_process: require('child_process'),
net: require('net'),
} : {}),
},
};
// Initialize Node-RED on the existing HTTP server (must be done before server.listen)
RED.init(server, redSettings);
const getCookieValue = (req, name) => {
const cookieHeader = req.headers.cookie;
if (!cookieHeader) return null;
const cookies = cookieHeader.split(';');
for (const cookie of cookies) {
const [key, ...rest] = cookie.trim().split('=');
if (key === name) {
return rest.join('=');
}
}
return null;
};
const verifyApiKey = (runtimeRef, apiKey) => {
return runtimeRef.apiKeys.getApiKeys().then(stored => {
const now = Date.now();
return stored.find(k => {
if (!k || k.key !== apiKey || k.enabled === false) {
return false;
}
if (!k.expires) {
return true;
}
const expiresAt = new Date(k.expires).getTime();
return !isNaN(expiresAt) && expiresAt > now;
});
});
};
// Allow public dashboard UI and socket.io; require JWT or API key for admin/editor/flows when security is enabled
const allowDashboard = (req, res, next) => {
const url = req.originalUrl || req.url || req.path;
// Public dashboard UI and its HTTP APIs (served from httpNodeRoot/ui.path)
if (url.includes('/dashboard') || url.includes('/socket.io')) return next();
if (!settings.secureEnabled || settings.nodeRedAuthMode === 'legacy-open') {
return next();
}
const apiKey = req.headers['x-api-key'];
if (apiKey) {
return verifyApiKey(runtime, apiKey)
.then(validKey => {
if (!validKey) {
return res.status(401).json({ error: "unauthorized_error", message: "Invalid API Key" });
}
return next();
})
.catch(err => {
logger.error(`api-key validation failed: ${err}`);
return res.status(500).json({ error: "unexpected_error", message: "ApiKey validation failed" });
});
}
const headerToken = req.headers['x-access-token'];
const queryToken = req.query?.token;
const cookieToken = getCookieValue(req, 'nodered_auth');
// Prefer explicit tokens over cookie to avoid stale cookie blocking valid logins
const token = headerToken || queryToken || cookieToken;
if (!token) {
return res.status(401).json({ error: "unauthorized_error", message: "Authentication required!" });
}
return authJwt.verify(token)
.then(() => {
if (queryToken) {
res.cookie('nodered_auth', token, {
httpOnly: true,
sameSite: 'lax',
secure: !!settings.https,
});
if (req.method === 'GET') {
const cleanUrl = new URL(req.originalUrl, `http://${req.headers.host}`);
cleanUrl.searchParams.delete('token');
return res.redirect(cleanUrl.pathname + cleanUrl.search);
}
}
return next();
})
.catch(() => {
if (cookieToken) {
res.clearCookie('nodered_auth');
}
return res.status(401).json({ error: "unauthorized_error", message: "Invalid token!" });
});
};
// Mount Node-RED admin/editor under /nodered; HTTP nodes (including dashboard)
// are served from httpNodeRoot ('/dashboard') so they appear at /dashboard/... etc.
app.use('/nodered', allowDashboard, RED.httpAdmin);
app.use('/dashboard', allowDashboard, RED.httpNode);
await RED.start();
RED.httpAdmin.get('/fuxa/devices', function(req, res) {
const devices = runtime.project.getDevices();
const result = [];
for (const id in devices) {
const device = devices[id];
const tags = [];
for (const tagId in device.tags) {
tags.push({ id: tagId, name: device.tags[tagId].name });
}
result.push({ id: device.id, name: device.name, tags });
}
res.json(result);
});
RED.httpAdmin.get('/fuxa/scripts', async function(req, res) {
try {
const scripts = await runtime.project.getScripts();
const result = scripts ? scripts.map(script => ({ id: script.id, name: script.name })) : [];
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
RED.httpAdmin.get('/fuxa/views', async function(req, res) {
try {
const project = await runtime.project.getProject();
const result = project && project.hmi && project.hmi.views ?
project.hmi.views.map(view => ({ id: view.id, name: view.name })) : [];
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
RED.httpAdmin.get('/fuxa/alarms', async function(req, res) {
try {
const alarms = await runtime.project.getAlarms();
const result = alarms ?
alarms.map(alarm => ({ id: alarm.id, name: alarm.name })) : [];
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// set api to listen (moved here to avoid rate limiting Node-RED routes)
if (settings.disableServer !== false) {
// Catch-all route for SPA - serve Angular index.html for client routes
// Exclude API routes and static assets
app.get('*', (req, res, next) => {
// Skip API routes and static assets
if (req.path.startsWith('/api/') ||
req.path.includes('.') ||
req.path.startsWith('/nodered') ||
req.path.startsWith('/dashboard')) {
return next();
}
res.sendFile(path.join(settings.httpStatic, 'index.html'));
});
}
logger.info('[Node-RED] Started at /nodered');
}
module.exports = { mountNodeRedIfInstalled };