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:
znetsixe
2026-05-10 18:59:50 +02:00
parent 57b77f905a
commit 62f389a51f
3 changed files with 534 additions and 1 deletions

View 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;