ARTICLE · 1357234
FUXA 源码解读 ⑩|集成与部署
01
一句话结论
FUXA 的三种交付形态(容器、桌面版、单 EXE)跑的是同一个 server/main.js,差别不在代码分支,而在进程外面套的那层壳:谁 fork 它、userDir 指到哪、端口从哪来。部署翻车的地方也集中在三处 —— _appdata/settings.js 生成后不再被覆盖、Electron 把端口写死成 1881、Node-RED 的兜底路由只在它自己启用时才补上。
02
入口与调用链
main.js 的前 100 行基本就是在回答"我在哪、我的数据放哪":
- main.js:59-79
:rootDir = __dirname、workDir = cwd/_appdata;如果 process.env.userDir 存在,两者都改到 userDir 下(:63-66),命令行 --userDir 优先级更高(:68-71);最后 mkdirSync(workDir)(:77-79)。 - main.js:85-99
:配置文件固定叫 <userDir>/_appdata/settings.js;不存在时才从 server/settings.default.js 复制一份(COPYFILE_EXCL,只复制一次)。 - main.js:271-286
:有 settings.https 就 https.createServer,否则 http.createServer;同一个 server 交给 socket.io(pingInterval 60s / pingTimeout 120s / allowEIO3)。 - main.js:343-352
:端口优先级是 -p 参数 > PORT 环境变量 > settings.uiPort > 1881;uiHost 缺省 0.0.0.0。 - main.js:355-363
:等 init-runtime-ok 事件才调 startFuxa(),然后 FUXA.init(server, io, settings, logger, events)。 - main.js:607-657
:startFuxa 里先挂 EADDRINUSE 处理(:610-622),再尝试挂 Node-RED(失败只 warn,:625-633),再按 disableServer 决定是否挂 FUXA.httpApi(:635-637),最后 server.listen(:639-643)。
settings.httpStatic 为空时走 :644-646 的 server.headless-mode —— 这是第三种形态的开关:只跑设备驱动、脚本、排程,不建 Web 服务。
03
核心数据结构
部署相关的东西其实只有"目录"和"配置文件"两样。
| <userDir>/_appdata/settings.js | main.js:85-99 | |
| _appdata/_db | users.fuxap.db | settings.default.js:43 |
| _appdata/_logs | settings.default.js:22 | |
| _appdata/_images | main.js:258-264 | |
| _appdata/_webcam_snapshots | settings.default.js:128 | |
| <userDir> | brand | main.js:82 |
容器的四个卷就是把 _appdata、_db、_logs、_images 各挂出来(compose.yml:5-9),所以升级镜像不丢数据,前提是你挂对了目录层级 —— 它们都挂在 /usr/src/app/FUXA/server/ 下,而不是 /usr/src/app/FUXA/。
settings.js 里跟部署强相关的几项:uiPort(默认 1881)、uiHost、httpStatic(空则 headless)、disableServer(true 则不挂 REST,仅编辑器)、nodeRedEnabled / nodeRedAuthMode、logApiLevel(默认 tiny,settings.default.js:39)。
04
关键流程
4.1 容器:四阶段构建
Dockerfile 分了四个构建阶段,每个都在解决一个具体问题:
- client-builder
(:9-14):固定在 $BUILDPLATFORM 上只构建一次 Angular 产物,多架构构建时共享,避免在 QEMU 下重复跑 ng build; - server-builder
(:17-56):装 python3/build-essential/libsqlite3-dev,npm prune --production 之后强制 sqlite3 从源码重编(:42),这是容器里 sqlite ABI 不匹配的经典解法;ODBC 与 Snap7 都是可选参数(INSTALL_ODBC / NODE_SNAP,:19-20、:38-50); - plugins-builder
(:71-97):把 11 个通讯包(OPC UA、EtherNet/IP、ADS、MC Protocol、BACnet、PCCC、Redis、ODBC、chartjs、webcam)预装到 /plugins_pkg,再由 runner 阶段整体拷进 server/_pkg(:143)。这样容器首启就带全部协议,不用在 UI 里一个个装; - runner
(:100-150):只留运行时库(libcairo2/libpango 是 canvas 的原生依赖,:105-114),ENV NODE_ENV=production、EXPOSE 1881、CMD node main.js。
4.2 桌面版:主进程只负责"拉起来再装窗口"
app/electron/main.js 的流程很短:fork 一个 server/main.js,env 里塞 userDir 和 PORT: 1881(:512-515);然后 waitForServer('http://localhost:1881', 30000, 1000) 轮询(:537),就绪后 win.loadURL('http://localhost:1881')(:542)。项目选择、最近项目、全屏、自动启动这些都在 Electron 侧,配置写在 %APPDATA%/fuxa-app/config.json(:52-54)。
也就是说桌面版并没有把服务端"嵌入式化",它就是个带窗口的进程管理器 —— 窗口崩了不影响服务,服务挂了窗口白屏。
4.3 单 EXE:同一份二进制,两种模式
headless-guardian.js 用环境变量 FUXA_SERVER 切换角色:
- FUXA_SERVER=1
(:33-64):解析 -p 写进 PORT,把数据目录定为 EXE 同级 data(:43-53),然后 require('./server/main.js'); 默认(守护模式):spawn(process.execPath, []) 拉起自己(:118-122),子进程退出就 2 秒后重启,最多 1000 次;10 秒内连续重启超过 5 次会强制冷却 10 秒(:75-80、:95-101)。
这里有两个很"实战"的细节。第一,pkg 不认命令行参数(会被当 JS 文件路径),所以端口靠环境变量传给子进程(:117);第二,pkg 快照下 spawn 的子进程句柄不保持父进程事件循环,父进程会"空闲退出"并连带杀掉子进程,于是用一个绝不 unref 的 15 秒 setInterval 强行保活(:157-167),注释把原因写得很清楚。
headless-entry.js 是同一思路的精简版:没有守护,直接 fork 并转发 SIGINT/SIGTERM(:44-65)。

三种壳子,一个内核
▲ 三条交付线都收敛到 server/main.js,差别是 userDir、端口来源和"崩了谁来拉起"。
4.4 Node-RED:挂在同一个 server 上
mountNodeRedIfInstalled(integrations/node-red/index.js:365 起)的关键顺序,main.js:625-627 在 server.listen 之前调用它:
先 installNodeRedWebSocketAuth(server, ...)(:470)—— 因为 Express 中间件对 HTTP upgrade 不生效,所以用 server.prependListener('upgrade') 抢在 Node-RED 自己的 upgrade 监听前面验签,失败直接写 401 并 socket.destroy()(:273-280); - RED.init(server, redSettings)
(:471)复用同一个 server; - /nodered
挂 RED.httpAdmin、/dashboard 挂 RED.httpNode,两者前面都套同一个中间件(:477-478); 注册 /fuxa/devices、/fuxa/scripts、/fuxa/views、/fuxa/alarms 四个只读端点给编辑器下拉框用(:482-526); 最后补 SPA 的 app.get('*')(:532-538)。
中间件的判定(:299-363):baseUrl === '/dashboard' 直接放行;legacy-open 模式整段跳过;否则 API Key 或"已登录且非 guest 且管理员",否则 401/403。注意它比 FUXA 自己的 REST 更严格 —— FUXA 会把坏 token 降级成 guest,Node-RED 直接 401。
flow 里能碰到的东西都挂在 functionGlobalContext.fuxa 上(:401-440):getTag / setTag / getDaq / getAlarms / getHistoryAlarms / ackAlarm / emit / on / sendMessage。另外顺手给了 fs、http、https、dgram、dns、crypto、sqlite3、serialport(:443-460),child_process 和 net 要显式打开 nodeRedUnsafeModules 才注入(:462-465)。

Node-RED 挂进同一个 HTTP 服务
▲ 一条 listen 上两套路由,危险点全在"Express 不管 upgrade"这一条上。
4.5 Swagger 是可选件
mountSwaggerIfEnabled(main.js:542-561)读 settings.swagger || settings.swaggerEnabled,缺 swagger-ui-express 或 yamljs 就只 warn 不报错,文档来自 server/docs/openapi.yaml,挂在 /api-docs。容器镜像里默认没装这两个包,所以"镜像里看不到 /api-docs"是预期的,不是坏了。
05
设计取舍与坑
配置只生成一次,之后不会跟着镜像升级变。settings.js 用 COPYFILE_EXCL 复制(main.js:93),已经有了就跳过。这解释了最常见的困惑:升级到新版本后,"默认值明明改了,现场还是老样子"。要改默认行为,必须手工编辑 _appdata/settings.js,或删掉它重启。
index.html 是启动时缓存进内存的。main.js:338 把整个 index.html 读成字符串,:500-505 用它直接响应所有 shell 路由。前端重新构建后不重启服务,浏览器拿到的仍是旧壳 —— 只有静态资源走 express.static(:513-515)会更新。这是取舍:省了每次读盘,代价是"改了没生效"。
Electron 的 1881 是硬编码的。:513 直接 PORT: 1881,没有回退逻辑。本机已有实例占着这个端口时,server.listen 触发 EADDRINUSE → process.exit(1)(main.js:610-621),桌面版只会表现为"加载 30 秒后报错",日志在子进程里,用户在窗口里看不到。
Node-RED 与 SPA 兜底是耦合的。app.get('*') 只写在 mountNodeRedIfInstalled 里(index.js:532-539),且外面套了 disableServer !== false。如果 Node-RED 挂载失败,main.js:628-630 只 warn 并继续 —— 那么这条兜底路由也不会被注册,"深链接直接访问 /view/xxx"就可能 404。这是顺序依赖,不是 bug,但排查时要先看日志里有没有 [Node-RED] Failed to initialize。
legacy-open 等于把编辑器交出去。nodeRedAuthMode === 'legacy-open' 时中间件在 index.js:306-308 直接 next(),/nodered 上的 admin、scripts、devices 全公开。这个模式存在是为了兼容老部署,新装环境不要选。
容器预装插件,非容器要自己装。server/_pkg 里的 11 个包只存在于镜像(Dockerfile:143)。用 EXE 或裸 node main.js 部署时,第一次连 OPC UA / ODBC 会提示缺模块,需要在 UI 里逐个安装,且需要编译工具链。
headless 模式没有 Web,也就没有 REST。httpStatic 为空时根本不 listen(main.js:644-646),disableServer=true 时连 FUXA.httpApi 都不挂(:635-637)。这时"接口调不通"不是权限问题,是服务压根没起。
06
小结
三句话:
部署的三个旋钮是 userDir、端口、httpStatic,代码本身在三种形态下完全一致; 真正会咬人的是"配置只生成一次"和"index.html 内存缓存",它们让"改了但没生效"变成默认体验; Node-RED 是可选外挂,挂在同一个 server 上,成功与否都不该阻塞主服务 —— 但它失败时 SPA 兜底会一起消失。
07
上线前必查 5 项
这五项都能在十分钟内查完,且都是"查得到证据"的,不是靠猜。
① 报警历史是否按 ontime 过滤(D-07)
怎么查:造一条跨零点的报警(23:50 触发、次日 00:10 恢复),用 /api/getAlarmsHistory 以"次日 00:00–01:00"为窗口查询;没有环境就直接看 _db 里 chronicle 表的 ontime 与恢复时间字段,再对照 alarmstorage.js:158 的 SQL 条件。
查到什么算有问题:该条在次日窗口里查不到,但活动报警表里它明明是当天恢复的。这说明历史查询只认开始时间,跨天报警会漏。
② 重启后活动报警的 value 是否还在(D-09)
怎么查:让一个数值报警处于 ON 状态,记下界面显示的具体数值,重启服务,再看报警看板和详情;同时 SELECT * FROM alarms(表结构见 alarmstorage.js:56)。
查到什么算有问题:重启后报警文字里的 %d 原样显示、没有被替换成数值。这说明活动表的持久化里没有 value 字段,重启只能恢复状态、恢复不了数值。
③ 新建标签的首个样本是否入库(D-11)
怎么查:新建一个数值标签并开启 DAQ,写入一次值,等一个采集周期,然后用 /api/getDaqValue 取当天数据,看第一条记录的时间戳;也可以直接查 daq-map_*.db 与 daq-data_*.db。
查到什么算有问题:map 表里已有这个点位,但 data 表的第一条记录明显晚于第一次写入(晚一个轮询周期以上)。对应 sqlite/index.js:201-205:map 是异步登记的,第一批值到达时点位还不在内存映射里。
④ DAQ 切卷周期是否真是配置值(D-10)
怎么查:打开 _appdata/settings.js 搜 daqTokenizer(默认 24),再看 _db 下 daq-data_<id>_<时间后缀>.db 的文件名,算相邻两卷的时间差。
查到什么算有问题:第一卷是 24 小时,第二卷起变成 1 小时。原因是初始化读的是 settings.daqTokenizer(sqlite/index.js:34),而切卷时读的是 settings.daqTimeToken(:147、:245),后者在默认配置里不存在,回退成 db_daqtoken(1 小时,:17)。
⑤ 定时脚本失败是否会被计数(D-14)
怎么查:建一个定时脚本,正文只写 throw new Error('boom'),触发一次后看脚本管理页的失败计数与日志。
查到什么算有问题:失败计数仍是 0,日志里只有一条普通输出。因为 msm.js:113 把异常包成了返回值,scripts/index.js:168-187 的失败分支永远进不去 —— 监控面板会"全绿",而脚本其实全挂。
系列到此收尾。十篇覆盖了从启动链路到部署形态的完整地图;疑似缺陷清单.md 已按 P0–P3 重排,可直接当工单池用。














































































