import type { BaseNode, UAReference, UAReferenceType } from "node-opcua-address-space-base"; import { assert } from "node-opcua-assert"; import { NodeClass, type QualifiedName, type QualifiedNameOptions } from "node-opcua-data-model"; import { make_debugLog, make_warningLog } from "node-opcua-debug"; import { makeNodeId, NodeId, type NodeIdLike, NodeIdType, resolveNodeId, sameNodeId } from "node-opcua-nodeid"; import { BaseNodeImpl } from "./base_node_impl.js"; import { type ReferenceImpl, resolveReferenceType } from "./reference_impl.js"; const _debugLog = make_debugLog("nodeid_manager"); const _warningLog = make_warningLog("nodeid_manager"); export const NamespaceOptions = { nodeIdNameSeparator: "-" }; function _isValidNodeClass(nodeClass: NodeClass) { return typeof NodeClass[nodeClass] === "string"; } const regExp1 = /^(s|i|b|g)=/; const regExp2 = /^ns=[0-9]+;(s|i|b|g)=/; const hasEncoding = resolveNodeId("HasEncoding"); type Suffix = string; function _filterAggregates(addressSpace: AddressSpacePartial, references: UAReference[]): [NodeId, Suffix] | null { const aggregatesRefType = addressSpace.findNode(resolveNodeId("Aggregates")) as UAReferenceType; const hasEncodinRefType = addressSpace.findNode(resolveNodeId("HasEncoding")) as UAReferenceType; const checkAggregate = (reference: UAReference): boolean => { if (reference.isForward) return false; const r = resolveReferenceType(addressSpace, reference); if (!r) { return false; } return r.isSubtypeOf(aggregatesRefType) || r.isSubtypeOf(hasEncodinRefType); }; const candidates = references.filter(checkAggregate); assert( candidates.length <= 1, "a node shall not have more than one parent (link to a parent with a reference derived from 'Aggregates')" ); if (candidates.length === 0) { return null; } const ref = candidates[0]; if (sameNodeId(ref.referenceType, hasEncoding)) { return [ref.nodeId, "_Encoding"]; } return [ref.nodeId, ""]; } function _findParentNodeId(addressSpace: AddressSpacePartial, options: ConstructNodeIdOptions): [NodeId, Suffix] | null { if (!options.references) { return null; } for (const ref of options.references) { const _ref = ref as ReferenceImpl; _ref._referenceType = addressSpace.findReferenceType(ref.referenceType) ?? undefined; /* c8 ignore next */ if (!_ref._referenceType) { throw new Error(`Cannot find referenceType ${JSON.stringify(ref)}`); } _ref.referenceType = _ref._referenceType.nodeId; } // find HasComponent, or has Property reverse return _filterAggregates(addressSpace, options.references); } function prepareName(browseName?: QualifiedName | QualifiedNameOptions): string { const m = browseName?.name?.toString().replace(/[ ]/g, "").replace(/(<|>)/g, ""); return m || ""; } export interface AddressSpacePartial { findNode(nodeId: NodeIdLike): BaseNode | null; findReferenceType(refType: NodeIdLike, namespaceIndex?: number): UAReferenceType | null; } export interface ConstructNodeIdOptions { nodeId?: string | NodeIdLike | BaseNode | null; browseName: QualifiedNameOptions; nodeClass?: NodeClass; references?: UAReference[]; registerSymbolicNames?: boolean; } export type NodeEntry = [string, number, NodeClass]; export type NodeEntry1 = [string, number, string /*"Object" | "Variable" etc...*/]; export class NodeIdManager { private _cacheSymbolicName: { [key: string]: [number, NodeClass] } = {}; private _cacheSymbolicNameRev: Set = new Set(); private _internal_id_counter: number; private namespaceIndex: number; private addressSpace: AddressSpacePartial; constructor(namespaceIndex: number, addressSpace: AddressSpacePartial) { this._internal_id_counter = 1000; this.namespaceIndex = namespaceIndex; this.addressSpace = addressSpace; } public setSymbols(symbols: NodeEntry1[]): void { function convertNodeClass(nodeClass: string): NodeClass { return (NodeClass as unknown as Record)[nodeClass]; } const symbols2 = symbols.map((e: [string, number, string]) => [e[0] as string, e[1] as number, convertNodeClass(e[2])]) as [ string, number, NodeClass ][]; for (const [name, value, nodeClass] of symbols2) { this._cacheSymbolicName[name] = [value, nodeClass]; this._cacheSymbolicNameRev.add(value); } } public getSymbols(): NodeEntry1[] { const line: NodeEntry1[] = []; for (const [key, [value, nodeClass1]] of Object.entries(this._cacheSymbolicName)) { const _node = this.addressSpace.findNode(makeNodeId(value, this.namespaceIndex)); const nodeClass = NodeClass[nodeClass1 || NodeClass.Unspecified]; line.push([key, value, nodeClass]); } return line; } public getSymbolCSV(): string { const line: string[] = []; for (const [name, value, nodeClass] of this.getSymbols()) { line.push([name, value, nodeClass].join(";")); } return line.join("\n"); } public buildNewNodeId(): NodeId { let nodeId: NodeId; do { nodeId = makeNodeId(this._internal_id_counter, this.namespaceIndex); this._internal_id_counter += 1; } while (this.addressSpace.findNode(nodeId) || this._isInCache(nodeId)); return nodeId; } public constructNodeId(options: ConstructNodeIdOptions): NodeId { const compose = (left: string, right: string) => { return right ? (left ? `${left}_${right}` : right) : left; }; const buildUpName2 = (nodeId: NodeId, suffix: string) => { const namespaceIndex = nodeId.namespace; let name = ""; let n: BaseNode | null = this.addressSpace.findNode(nodeId); while (n && n.nodeId.namespace === namespaceIndex) { const e = prepareName(n.browseName) + suffix; name = compose(e, name); n = n.parentNodeId ? this.addressSpace.findNode(n.parentNodeId) : null; } return name; }; if (!options.nodeId && options.registerSymbolicNames) { const parentInfo = this.findParentNodeId(options); let fullParentName = ""; if (parentInfo) { const [parentNodeId, suffix] = parentInfo; fullParentName = buildUpName2(parentNodeId, suffix); } const fullName = compose(fullParentName, prepareName(options.browseName)); const cached = this._cacheSymbolicName[fullName]; if (cached && this._isCacheHitReusable(cached[0], options, parentInfo)) { return makeNodeId(cached[0], this.namespaceIndex); } const nodeId = this._constructNodeId(options); if (nodeId.identifierType === NodeIdType.NUMERIC && !cached) { this._cacheSymbolicName[fullName] = [nodeId.value as number, options.nodeClass || NodeClass.Unspecified]; this._cacheSymbolicNameRev.add(nodeId.value as number); } return nodeId; } const nodeId = this._constructNodeId(options); // When registerSymbolicNames is true and a nodeId was already // provided (e.g. loaded from XML during reverse engineering), // also record the BrowseName → NodeId mapping in the symbol // cache so that getSymbols() returns the original NodeIds. if (options.registerSymbolicNames && nodeId.identifierType === NodeIdType.NUMERIC) { const parentInfo = this.findParentNodeId(options); let fullParentName = ""; if (parentInfo) { const [parentNodeId, suffix] = parentInfo; fullParentName = buildUpName2(parentNodeId, suffix); } const fullName = compose(fullParentName, prepareName(options.browseName)); if (!this._cacheSymbolicName[fullName]) { this._cacheSymbolicName[fullName] = [nodeId.value as number, options.nodeClass || NodeClass.Unspecified]; this._cacheSymbolicNameRev.add(nodeId.value as number); } } return nodeId; } /** * A symbol-cache hit is only safe to reuse when it actually refers to * the node we are about to construct. Because the cache key is built * from a (possibly truncated, e.g. during a clone where parentNodeId * isn't linked yet) parent-name chain, two unrelated nodes can end up * sharing the same key. When that happens, reusing the cached nodeId * would hand a brand-new node the identity of a completely different, * already-registered one. * * We only trust the cache hit when either: * - nothing occupies that nodeId yet (first-time reservation), or * - the occupant is genuinely the same node (same browseName AND same * parent) - in which case we deliberately return the same id so * that the caller's duplicate-registration error still fires. */ private _isCacheHitReusable(value: number, options: ConstructNodeIdOptions, parentInfo: [NodeId, Suffix] | null): boolean { const candidate = makeNodeId(value, this.namespaceIndex); const occupant = this.addressSpace.findNode(candidate); if (!occupant) { return true; } // options.browseName is expected to already carry its real, resolved namespaceIndex by the // time it reaches here: internalCreateNode() normalizes plain-string browseNames to // `namespaceIndex: this.index` before construction, so `0` here means "explicitly namespace 0" // (e.g. the standard "0:EURange" / "0:EngineeringUnits" properties), not "unspecified". // Comparing literally keeps genuinely different namespaces (own-namespace vs explicit ns 0) // from being treated as the same node. const sameBrowseName = !!occupant.browseName && occupant.browseName.name === (options.browseName?.name ?? null) && (occupant.browseName.namespaceIndex ?? 0) === (options.browseName?.namespaceIndex ?? 0); const expectedParentNodeId = parentInfo ? parentInfo[0] : null; const sameParent = expectedParentNodeId ? !!occupant.parentNodeId && sameNodeId(occupant.parentNodeId, expectedParentNodeId) : !occupant.parentNodeId; return sameBrowseName && sameParent; } private _constructNodeId(options: ConstructNodeIdOptions): NodeId { const resolveNodeIdEx = (nodeId: BaseNode | NodeIdLike) => nodeId && typeof nodeId === "object" && nodeId instanceof BaseNodeImpl ? nodeId.nodeId : resolveNodeId(nodeId as NodeIdLike); let nodeId = options.nodeId; if (!nodeId) { const parentInfo = this.findParentNodeId(options); if (parentInfo) { const [parentNodeId, _linkName] = parentInfo; const name = prepareName(options.browseName); nodeId = null; if (parentNodeId.identifierType === NodeId.NodeIdType.STRING) { // combining string nodeId => not stored in chache const childName = parentNodeId.value + NamespaceOptions.nodeIdNameSeparator + name; nodeId = new NodeId(NodeId.NodeIdType.STRING, childName, parentNodeId.namespace); return nodeId; } } } else if (typeof nodeId === "string") { if (this.namespaceIndex !== 0) { if (nodeId.match(regExp2)) { // nothing } else if (nodeId.match(regExp1)) { nodeId = `ns=${this.namespaceIndex};${nodeId}`; // } else { // nodeId = this._getOrCreateFromName(nodeId, nodeClass); } } } nodeId = nodeId || this.buildNewNodeId(); if (nodeId instanceof NodeId) { assert(nodeId.namespace === this.namespaceIndex); return nodeId; } nodeId = resolveNodeIdEx(nodeId); assert(nodeId.namespace === this.namespaceIndex); return nodeId; } public findParentNodeId(options: ConstructNodeIdOptions): [NodeId, Suffix] | null { return _findParentNodeId(this.addressSpace, options); } private _isInCache(nodeId: NodeId): boolean { if (nodeId.namespace !== this.namespaceIndex || nodeId.identifierType !== NodeIdType.NUMERIC) return false; return !!this._cacheSymbolicNameRev.has(nodeId.value as number); } }