2026-05-10 18:31:50 +02:00
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
// Declarative dispatch for a node's input topics. Each node declares its
|
|
|
|
|
// commands as an array of descriptors; the registry builds an O(1) lookup
|
|
|
|
|
// keyed by canonical topic + alias, validates the payload against a small
|
|
|
|
|
// shape schema, and invokes the handler. Replaces the per-node ~100-line
|
|
|
|
|
// `switch (msg.topic)` block in nodeClass._attachInputHandler.
|
|
|
|
|
//
|
|
|
|
|
// Lightweight on purpose: the schema is a typeof-check ladder, not full
|
|
|
|
|
// JSON-Schema. Anything richer belongs in the handler itself, which has
|
|
|
|
|
// access to logger via ctx.
|
|
|
|
|
|
2026-05-11 17:13:15 +02:00
|
|
|
const SCALAR_TYPES = new Set(['string', 'number', 'boolean', 'object', 'any', 'none']);
|
2026-05-10 18:31:50 +02:00
|
|
|
|
|
|
|
|
class CommandRegistry {
|
|
|
|
|
constructor(commands, options = {}) {
|
|
|
|
|
if (!Array.isArray(commands)) {
|
|
|
|
|
throw new TypeError('CommandRegistry requires an array of command descriptors');
|
|
|
|
|
}
|
|
|
|
|
this._logger = options.logger || null;
|
|
|
|
|
this._byKey = new Map(); // topic-or-alias -> descriptor
|
|
|
|
|
this._canonicalByAlias = new Map();
|
|
|
|
|
this._descriptors = [];
|
|
|
|
|
this._deprecationCounts = new Map();
|
|
|
|
|
this._deprecationLogged = new Set();
|
|
|
|
|
for (const cmd of commands) this._register(cmd);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_register(cmd) {
|
|
|
|
|
if (!cmd || typeof cmd.topic !== 'string' || cmd.topic.length === 0) {
|
|
|
|
|
throw new TypeError('command descriptor requires a non-empty string topic');
|
|
|
|
|
}
|
|
|
|
|
if (typeof cmd.handler !== 'function') {
|
|
|
|
|
throw new TypeError(`command '${cmd.topic}' requires a handler function`);
|
|
|
|
|
}
|
|
|
|
|
if (this._byKey.has(cmd.topic)) {
|
|
|
|
|
throw new Error(`duplicate command topic '${cmd.topic}'`);
|
|
|
|
|
}
|
|
|
|
|
const aliases = Array.isArray(cmd.aliases) ? cmd.aliases.slice() : [];
|
|
|
|
|
for (const alias of aliases) {
|
|
|
|
|
if (typeof alias !== 'string' || alias.length === 0) {
|
|
|
|
|
throw new TypeError(`command '${cmd.topic}' has an invalid alias`);
|
|
|
|
|
}
|
|
|
|
|
if (this._byKey.has(alias)) {
|
|
|
|
|
throw new Error(`alias '${alias}' for '${cmd.topic}' collides with existing topic or alias`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const descriptor = {
|
|
|
|
|
topic: cmd.topic,
|
|
|
|
|
aliases,
|
|
|
|
|
payloadSchema: cmd.payloadSchema || null,
|
2026-05-11 17:13:15 +02:00
|
|
|
description: typeof cmd.description === 'string' ? cmd.description : null,
|
2026-05-10 18:31:50 +02:00
|
|
|
handler: cmd.handler,
|
|
|
|
|
};
|
|
|
|
|
this._byKey.set(cmd.topic, descriptor);
|
|
|
|
|
for (const alias of aliases) {
|
|
|
|
|
this._byKey.set(alias, descriptor);
|
|
|
|
|
this._canonicalByAlias.set(alias, cmd.topic);
|
|
|
|
|
}
|
|
|
|
|
this._descriptors.push(descriptor);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
has(topic) {
|
|
|
|
|
return typeof topic === 'string' && this._byKey.has(topic);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
canonical(topic) {
|
|
|
|
|
if (typeof topic !== 'string') return topic;
|
|
|
|
|
return this._canonicalByAlias.get(topic) || topic;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
list() {
|
|
|
|
|
// Strip handler so callers can safely log / serialise the result
|
|
|
|
|
// (handler functions are noisy and not contract-relevant).
|
|
|
|
|
return this._descriptors.map((d) => ({
|
|
|
|
|
topic: d.topic,
|
|
|
|
|
aliases: d.aliases.slice(),
|
|
|
|
|
payloadSchema: d.payloadSchema,
|
2026-05-11 17:13:15 +02:00
|
|
|
description: d.description,
|
2026-05-10 18:31:50 +02:00
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deprecationStats() {
|
|
|
|
|
const out = {};
|
|
|
|
|
for (const [alias, count] of this._deprecationCounts) out[alias] = count;
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async dispatch(msg, source, ctx) {
|
|
|
|
|
const log = this._loggerFor(ctx);
|
|
|
|
|
const topic = msg && typeof msg.topic === 'string' ? msg.topic : null;
|
|
|
|
|
if (!topic) {
|
|
|
|
|
log.warn?.('commandRegistry: msg has no topic; ignoring');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const descriptor = this._byKey.get(topic);
|
|
|
|
|
if (!descriptor) {
|
|
|
|
|
log.warn?.(`commandRegistry: unknown topic '${topic}'`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (topic !== descriptor.topic) this._noteAlias(topic, descriptor.topic, log);
|
|
|
|
|
if (!this._validatePayload(descriptor, msg, log)) return;
|
|
|
|
|
return descriptor.handler(source, msg, ctx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_noteAlias(alias, canonical, log) {
|
|
|
|
|
const prev = this._deprecationCounts.get(alias) || 0;
|
|
|
|
|
this._deprecationCounts.set(alias, prev + 1);
|
|
|
|
|
if (this._deprecationLogged.has(alias)) return;
|
|
|
|
|
this._deprecationLogged.add(alias);
|
|
|
|
|
log.warn?.(`topic '${alias}' is deprecated; use '${canonical}'`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_validatePayload(descriptor, msg, log) {
|
|
|
|
|
const schema = descriptor.payloadSchema;
|
|
|
|
|
if (!schema) return true;
|
|
|
|
|
const payload = msg.payload;
|
|
|
|
|
const type = schema.type || 'any';
|
|
|
|
|
if (!SCALAR_TYPES.has(type)) {
|
|
|
|
|
log.warn?.(`commandRegistry: command '${descriptor.topic}' has unknown schema type '${type}'`);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (type === 'any') return true;
|
2026-05-11 17:13:15 +02:00
|
|
|
if (type === 'none') {
|
|
|
|
|
if (payload !== undefined && payload !== null) {
|
|
|
|
|
log.warn?.(`${descriptor.topic}: payload ignored — this is a trigger-only topic`);
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-05-10 18:31:50 +02:00
|
|
|
// typeof null === 'object' — explicit null fails an object schema.
|
|
|
|
|
if (type === 'object') {
|
|
|
|
|
if (payload === null || typeof payload !== 'object') {
|
|
|
|
|
log.warn?.(`commandRegistry: '${descriptor.topic}' expected object payload, got ${payload === null ? 'null' : typeof payload}`);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
} else if (typeof payload !== type) {
|
|
|
|
|
log.warn?.(`commandRegistry: '${descriptor.topic}' expected ${type} payload, got ${typeof payload}`);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (type === 'object' && schema.properties && typeof schema.properties === 'object') {
|
|
|
|
|
for (const [key, expected] of Object.entries(schema.properties)) {
|
|
|
|
|
if (!(key in payload)) continue; // missing keys allowed
|
|
|
|
|
if (typeof payload[key] !== expected) {
|
|
|
|
|
log.warn?.(`commandRegistry: '${descriptor.topic}' payload.${key} expected ${expected}, got ${typeof payload[key]}`);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_loggerFor(ctx) {
|
|
|
|
|
const candidate = (ctx && ctx.logger) || this._logger;
|
|
|
|
|
return candidate || NOOP_LOGGER;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const NOOP_LOGGER = { warn() {}, error() {}, info() {}, debug() {} };
|
|
|
|
|
|
|
|
|
|
function createRegistry(commands, options) {
|
|
|
|
|
return new CommandRegistry(commands, options);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = { createRegistry, CommandRegistry };
|