Phase 1 wave 3 + barrel: BaseNodeAdapter + index.js exports
- src/nodered/BaseNodeAdapter.js — base class for every nodeClass.js Lifecycle: config build → domain instantiate → child.register on Port 2 → tick (opt-in) or 'output-changed' subscription (default event-driven) → status updater → input dispatch via commandRegistry → close handler with clean teardown. - index.js — additive exports of all Phase 1 modules: UnitPolicy, ChildRouter, LatestWinsGate, HealthStatus, BaseDomain, statusBadge, StatusUpdater, createRegistry, CommandRegistry, BaseNodeAdapter, stats. Existing exports unchanged. 113 unit tests pass under node:test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
176
src/nodered/BaseNodeAdapter.js
Normal file
176
src/nodered/BaseNodeAdapter.js
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* BaseNodeAdapter — shared nodeClass scaffolding.
|
||||
*
|
||||
* Consolidates the boilerplate every node's nodeClass.js repeats today
|
||||
* (config build → domain instantiate → registration delay → tick loop →
|
||||
* status loop → input dispatch → close handler). Subclasses declare what
|
||||
* varies (DomainClass, commands, output strategy) via static fields and
|
||||
* override `buildDomainConfig(uiConfig, nodeId)` to produce the per-node
|
||||
* config slice.
|
||||
*
|
||||
* See CONTRACTS.md §2; OPEN_QUESTIONS.md (event-driven default + tick
|
||||
* fire-and-forget resolution, 2026-05-10).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const ConfigManager = require('../configs/index.js');
|
||||
const OutputUtils = require('../helper/outputUtils.js');
|
||||
const { createRegistry } = require('./commandRegistry.js');
|
||||
const { StatusUpdater } = require('./statusUpdater.js');
|
||||
|
||||
const REGISTRATION_DELAY_MS = 100;
|
||||
|
||||
class BaseNodeAdapter {
|
||||
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
|
||||
const ctor = this.constructor;
|
||||
if (ctor === BaseNodeAdapter) {
|
||||
throw new Error('BaseNodeAdapter is abstract; subclass it and declare static DomainClass + commands');
|
||||
}
|
||||
if (typeof ctor.DomainClass !== 'function') {
|
||||
throw new Error(`${ctor.name}: static DomainClass is required (a class to instantiate)`);
|
||||
}
|
||||
if (!Array.isArray(ctor.commands)) {
|
||||
throw new Error(`${ctor.name}: static commands is required (array of descriptors; use [] for none)`);
|
||||
}
|
||||
if (typeof this.buildDomainConfig !== 'function') {
|
||||
throw new Error(`${ctor.name}: must implement buildDomainConfig(uiConfig, nodeId)`);
|
||||
}
|
||||
|
||||
this.node = nodeInstance;
|
||||
this.RED = RED;
|
||||
this.name = nameOfNode;
|
||||
|
||||
const cfgMgr = new ConfigManager();
|
||||
this.defaultConfig = cfgMgr.getConfig(this.name);
|
||||
this.config = cfgMgr.buildConfig(
|
||||
this.name,
|
||||
uiConfig,
|
||||
this.node.id,
|
||||
this.buildDomainConfig(uiConfig, this.node.id) || {},
|
||||
);
|
||||
|
||||
this.source = new ctor.DomainClass(this.config);
|
||||
// Sibling-node lookup uses RED.nodes.getNode(id).source — see existing
|
||||
// pumpingStation/measurement nodeClass _attachInputHandler patterns.
|
||||
this.node.source = this.source;
|
||||
|
||||
this._output = new OutputUtils();
|
||||
this._commands = createRegistry(ctor.commands, { logger: this.source?.logger });
|
||||
|
||||
this._tickInterval = null;
|
||||
this._outputChangedListener = null;
|
||||
this._scheduleRegistration();
|
||||
this._wireOutputs();
|
||||
|
||||
this._statusUpdater = new StatusUpdater({
|
||||
node: this.node,
|
||||
source: this.source,
|
||||
intervalMs: ctor.statusInterval ?? 1000,
|
||||
logger: this.source?.logger,
|
||||
});
|
||||
this._statusUpdater.start();
|
||||
|
||||
this._attachInputHandler();
|
||||
this._attachCloseHandler();
|
||||
|
||||
if (typeof this.extraSetup === 'function') this.extraSetup();
|
||||
}
|
||||
|
||||
_scheduleRegistration() {
|
||||
// Delayed so siblings have finished constructing before the parent
|
||||
// receives the registration message.
|
||||
setTimeout(() => {
|
||||
this.node.send([
|
||||
null,
|
||||
null,
|
||||
{
|
||||
topic: 'child.register',
|
||||
payload: this.node.id,
|
||||
positionVsParent: this.config?.functionality?.positionVsParent ?? 'atEquipment',
|
||||
distance: this.config?.functionality?.distance ?? null,
|
||||
},
|
||||
]);
|
||||
}, REGISTRATION_DELAY_MS);
|
||||
}
|
||||
|
||||
_wireOutputs() {
|
||||
const ctor = this.constructor;
|
||||
const interval = ctor.tickInterval;
|
||||
if (typeof interval === 'number' && interval > 0) {
|
||||
this._tickInterval = setInterval(() => {
|
||||
// Fire-and-forget per OPEN_QUESTIONS 2026-05-10. Domain owns
|
||||
// its own serialisation via LatestWinsGate when needed.
|
||||
try { this.source.tick?.(); }
|
||||
catch (err) { this.source?.logger?.error?.(`tick threw: ${err.message}`); }
|
||||
this._emitOutputs();
|
||||
}, interval);
|
||||
return;
|
||||
}
|
||||
// Event-driven default: domain emits 'output-changed' when its
|
||||
// public output state shifts; adapter pushes outputs in response.
|
||||
const emitter = this.source?.emitter;
|
||||
if (emitter && typeof emitter.on === 'function') {
|
||||
this._outputChangedListener = () => this._emitOutputs();
|
||||
emitter.on('output-changed', this._outputChangedListener);
|
||||
}
|
||||
}
|
||||
|
||||
_emitOutputs() {
|
||||
if (typeof this.source.getOutput !== 'function') return;
|
||||
const raw = this.source.getOutput();
|
||||
const cfg = this.source.config || this.config;
|
||||
const processMsg = this._output.formatMsg(raw, cfg, 'process');
|
||||
const influxMsg = this._output.formatMsg(raw, cfg, 'influxdb');
|
||||
this.node.send([processMsg, influxMsg, null]);
|
||||
}
|
||||
|
||||
_attachInputHandler() {
|
||||
this.node.on('input', async (msg, send, done) => {
|
||||
try {
|
||||
await this._commands.dispatch(msg, this.source, {
|
||||
node: this.node,
|
||||
RED: this.RED,
|
||||
send,
|
||||
logger: this.source?.logger,
|
||||
});
|
||||
if (typeof this.extraInputDispatch === 'function') {
|
||||
await this.extraInputDispatch(msg, send, done);
|
||||
}
|
||||
} catch (err) {
|
||||
this.source?.logger?.error?.(err.message);
|
||||
} finally {
|
||||
if (typeof done === 'function') done();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_attachCloseHandler() {
|
||||
this.node.on('close', (done) => {
|
||||
try {
|
||||
if (this._tickInterval) {
|
||||
clearInterval(this._tickInterval);
|
||||
this._tickInterval = null;
|
||||
}
|
||||
if (this._outputChangedListener && this.source?.emitter?.off) {
|
||||
this.source.emitter.off('output-changed', this._outputChangedListener);
|
||||
this._outputChangedListener = null;
|
||||
}
|
||||
this._statusUpdater?.stop();
|
||||
this.source?.close?.();
|
||||
if (typeof this.extraClose === 'function') this.extraClose();
|
||||
try { this.node.status({}); } catch (_) { /* best effort */ }
|
||||
} catch (err) {
|
||||
this.source?.logger?.error?.(`close handler threw: ${err.message}`);
|
||||
} finally {
|
||||
if (typeof done === 'function') done();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Defaults overridable via subclass static fields.
|
||||
BaseNodeAdapter.tickInterval = null;
|
||||
BaseNodeAdapter.statusInterval = 1000;
|
||||
|
||||
module.exports = BaseNodeAdapter;
|
||||
Reference in New Issue
Block a user