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:29:14 +02:00
const convert = require ( '../convert' ) ;
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
2026-05-11 17:29:14 +02:00
function _acceptedList ( measure ) {
if ( convert && typeof convert . possibilities === 'function' ) {
const list = convert . possibilities ( measure ) ;
if ( Array . isArray ( list ) && list . length ) return list . join ( ', ' ) ;
}
return '(see convert docs)' ;
}
function _describeUnit ( unit ) {
try { return convert ( ) . describe ( unit ) ; } catch ( _ ) { return null ; }
}
function _extractValueAndUnit ( msg ) {
if ( ! msg || typeof msg !== 'object' ) return null ;
const p = msg . payload ;
if ( typeof p === 'number' ) return { value : p , unit : msg . unit } ;
if ( p && typeof p === 'object' && typeof p . value === 'number' ) {
return { value : p . value , unit : p . unit ? ? msg . unit } ;
}
return null ;
}
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 ` ) ;
}
}
2026-05-11 17:29:14 +02:00
const units = this . _validateUnits ( cmd ) ;
2026-05-10 18:31:50 +02:00
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-11 17:29:14 +02:00
units ,
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 ) ;
}
2026-05-11 17:29:14 +02:00
_validateUnits ( cmd ) {
if ( cmd . units === undefined || cmd . units === null ) return null ;
const { measure , default : def } = cmd . units ;
if ( typeof measure !== 'string' || measure . length === 0 ||
typeof def !== 'string' || def . length === 0 ) {
throw new TypeError (
` command ' ${ cmd . topic } ' units requires { measure: string, default: string } ` ) ;
}
return { measure , default : def } ;
}
2026-05-10 18:31:50 +02:00
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-11 17:29:14 +02:00
units : d . units ? { measure : d . units . measure , default : d . units . default } : null ,
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 ) ;
2026-05-11 17:29:14 +02:00
if ( descriptor . units ) this . _normaliseUnits ( descriptor , msg , log ) ;
2026-05-10 18:31:50 +02:00
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 } ' ` ) ;
}
2026-05-11 17:29:14 +02:00
_normaliseUnits ( descriptor , msg , log ) {
const { measure , default : defaultUnit } = descriptor . units ;
const extracted = _extractValueAndUnit ( msg ) ;
if ( ! extracted ) return ; // unknown shape — let payload validator handle it
let { value , unit } = extracted ;
if ( unit === undefined || unit === null || unit === '' ) {
// No unit supplied — assume default, silent.
msg . payload = value ;
msg . unit = defaultUnit ;
return ;
}
const desc = _describeUnit ( unit ) ;
if ( ! desc ) {
log . warn ? . ( ` ${ descriptor . topic } : unknown unit ' ${ unit } '. Accepted: ${ _acceptedList ( measure ) } . Treating ${ value } as ${ defaultUnit } . ` ) ;
msg . payload = value ;
msg . unit = defaultUnit ;
return ;
}
if ( desc . measure !== measure ) {
log . warn ? . ( ` ${ descriptor . topic } : unit ' ${ unit } ' is ${ desc . measure } , expected ${ measure } . Accepted: ${ _acceptedList ( measure ) } . Treating ${ value } as ${ defaultUnit } . ` ) ;
msg . payload = value ;
msg . unit = defaultUnit ;
return ;
}
try {
msg . payload = convert ( value ) . from ( unit ) . to ( defaultUnit ) ;
msg . unit = defaultUnit ;
} catch ( err ) {
log . warn ? . ( ` ${ descriptor . topic } : failed to convert ${ value } ${ unit } -> ${ defaultUnit } ( ${ err . message } ). Treating as ${ defaultUnit } . ` ) ;
msg . payload = value ;
msg . unit = defaultUnit ;
}
}
2026-05-10 18:31:50 +02:00
_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 } ;