node-opcua-alias-name-server 2.176.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +361 -0
  3. package/dist/add_alias.d.ts +73 -0
  4. package/dist/add_alias.js +262 -0
  5. package/dist/add_alias.js.map +1 -0
  6. package/dist/address_space_alias_store.d.ts +130 -0
  7. package/dist/address_space_alias_store.js +360 -0
  8. package/dist/address_space_alias_store.js.map +1 -0
  9. package/dist/alias_hierarchy.d.ts +45 -0
  10. package/dist/alias_hierarchy.js +135 -0
  11. package/dist/alias_hierarchy.js.map +1 -0
  12. package/dist/alias_index.d.ts +60 -0
  13. package/dist/alias_index.js +119 -0
  14. package/dist/alias_index.js.map +1 -0
  15. package/dist/alias_name_archive.d.ts +45 -0
  16. package/dist/alias_name_archive.js +75 -0
  17. package/dist/alias_name_archive.js.map +1 -0
  18. package/dist/bind_alias_category.d.ts +173 -0
  19. package/dist/bind_alias_category.js +417 -0
  20. package/dist/bind_alias_category.js.map +1 -0
  21. package/dist/bind_configuration_methods.d.ts +44 -0
  22. package/dist/bind_configuration_methods.js +175 -0
  23. package/dist/bind_configuration_methods.js.map +1 -0
  24. package/dist/bind_find_alias.d.ts +61 -0
  25. package/dist/bind_find_alias.js +227 -0
  26. package/dist/bind_find_alias.js.map +1 -0
  27. package/dist/index.d.ts +18 -0
  28. package/dist/index.js +64 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/install_alias_names.d.ts +229 -0
  31. package/dist/install_alias_names.js +146 -0
  32. package/dist/install_alias_names.js.map +1 -0
  33. package/dist/last_change.d.ts +87 -0
  34. package/dist/last_change.js +174 -0
  35. package/dist/last_change.js.map +1 -0
  36. package/dist/well_known.d.ts +88 -0
  37. package/dist/well_known.js +93 -0
  38. package/dist/well_known.js.map +1 -0
  39. package/package.json +54 -0
  40. package/source/add_alias.ts +318 -0
  41. package/source/address_space_alias_store.ts +417 -0
  42. package/source/alias_hierarchy.ts +141 -0
  43. package/source/alias_index.ts +127 -0
  44. package/source/alias_name_archive.ts +84 -0
  45. package/source/bind_alias_category.ts +546 -0
  46. package/source/bind_configuration_methods.ts +219 -0
  47. package/source/bind_find_alias.ts +290 -0
  48. package/source/index.ts +74 -0
  49. package/source/install_alias_names.ts +342 -0
  50. package/source/last_change.ts +201 -0
  51. package/source/well_known.ts +101 -0
@@ -0,0 +1,417 @@
1
+ /**
2
+ * @module node-opcua-alias-name-server
3
+ *
4
+ * An {@link IAliasStore} backed directly by the address space.
5
+ *
6
+ * This is the default store, and the reason `installAliasNames(server)` needs no
7
+ * application code: a Server whose NodeSet2.xml already models `AliasNameType`
8
+ * instances answers `FindAlias` correctly with nothing else configured. The
9
+ * address space *is* the database.
10
+ */
11
+
12
+ import type { IAddressSpace, UAObject } from "node-opcua-address-space-base";
13
+ import {
14
+ type AliasEntry,
15
+ type AliasQuery,
16
+ type IAliasStore,
17
+ type LikeOptions,
18
+ LikePattern,
19
+ maxVersionTime,
20
+ nowVersionTime
21
+ } from "node-opcua-alias-name-common";
22
+ import { BrowseDirection, NodeClass } from "node-opcua-data-model";
23
+ import { ExpandedNodeId, type NodeId, NodeId as NodeIdClass, type NodeIdType } from "node-opcua-nodeid";
24
+ import { type StatusCode, StatusCodes } from "node-opcua-status-code";
25
+ import { addAlias, findAlias, removeAlias } from "./add_alias.js";
26
+ import { aliasesOf, collectCategories } from "./alias_hierarchy.js";
27
+ import { ALIAS_FOR } from "./well_known.js";
28
+
29
+ /**
30
+ * An ExpandedNodeId as a NodeId on this Server, dropping the ServerIndex.
31
+ *
32
+ * Clause 6.3.4 Table 9: *"The ServerIndex in the ExpandedNodeId shall be ignored
33
+ * and the TargetServers Uri shall be used."*
34
+ *
35
+ * Built from the identifier's own parts rather than by re-parsing its string
36
+ * form, which would have to cope with `svr=`, `nsu=` and quoting. When the
37
+ * ExpandedNodeId carries a namespace URI, that URI decides the index — the
38
+ * numeric index travelling beside it belongs to the *sending* Server's namespace
39
+ * table, not ours.
40
+ */
41
+ function toLocalNodeId(addressSpace: IAddressSpace, nodeId: ExpandedNodeId): NodeId | null {
42
+ let namespaceIndex = nodeId.namespace ?? 0;
43
+ if (nodeId.namespaceUri) {
44
+ const resolved = addressSpace.getNamespaceIndex(nodeId.namespaceUri);
45
+ if (resolved < 0) {
46
+ // a namespace this Server does not know: the Node cannot be here
47
+ return null;
48
+ }
49
+ namespaceIndex = resolved;
50
+ }
51
+ return new NodeIdClass(nodeId.identifierType, nodeId.value, namespaceIndex);
52
+ }
53
+
54
+ export interface AddressSpaceAliasStoreOptions {
55
+ /** Passed through to the OPC 10000-4 `Like` matcher. */
56
+ likeOptions?: LikeOptions;
57
+ /**
58
+ * Accept `AddAliasesToCategory` entries whose target is on another Server.
59
+ *
60
+ * Off by default. Clause 6.3.4 Table 10 makes `Bad_NotSupported` an
61
+ * explicitly allowed answer — *"Support for remote Server TargetNodes is
62
+ * optional"* — and storing a reference this Server can never resolve or
63
+ * verify is a poor default. When on, such entries are accepted and reported
64
+ * `Uncertain_ReferenceOutOfServer`, since this Server does not check Nodes
65
+ * on other Servers.
66
+ */
67
+ allowRemoteTargets?: boolean;
68
+ }
69
+
70
+ /** Turn a local NodeId into an ExpandedNodeId with the namespace URI filled in. */
71
+ function toExpandedNodeId(addressSpace: IAddressSpace, nodeId: NodeId): ExpandedNodeId {
72
+ const namespaceUri = nodeId.namespace === 0 ? null : addressSpace.getNamespaceUri(nodeId.namespace);
73
+ return new ExpandedNodeId(
74
+ nodeId.identifierType as unknown as NodeIdType,
75
+ nodeId.value,
76
+ nodeId.namespace,
77
+ namespaceUri,
78
+ // clause 7.3: the ServerIndex is carried separately in ServerUris, and
79
+ // clause 6.3.4 Table 9 says an incoming ServerIndex is ignored anyway
80
+ 0
81
+ );
82
+ }
83
+
84
+ export class AddressSpaceAliasStore implements IAliasStore {
85
+ private readonly addressSpace: IAddressSpace;
86
+ private readonly likeOptions?: LikeOptions;
87
+ /** Per-category `LastChange`, as a VersionTime (clause 6.3.1). */
88
+ private readonly lastChangeByCategory = new Map<string, number>();
89
+
90
+ private readonly allowRemoteTargets: boolean;
91
+
92
+ constructor(addressSpace: IAddressSpace, options?: AddressSpaceAliasStoreOptions) {
93
+ this.addressSpace = addressSpace;
94
+ this.likeOptions = options?.likeOptions;
95
+ this.allowRemoteTargets = options?.allowRemoteTargets ?? false;
96
+ }
97
+
98
+ /**
99
+ * Every alias at or below `query.categoryNodeId` matching the pattern.
100
+ *
101
+ * The search is recursive from the category the Method was called on
102
+ * (clause 6.3.1), so a call on `Aliases` also covers `TagVariables`,
103
+ * `Topics` and anything nested below them.
104
+ *
105
+ * One entry is produced per (AliasName, category) pair: `FindAliasVerbose`
106
+ * has to name the category that actually held the alias, which for a nested
107
+ * hit is the nested one, not the one that was called.
108
+ */
109
+ public async find(query: AliasQuery): Promise<AliasEntry[]> {
110
+ const root = this.addressSpace.findNode(query.categoryNodeId);
111
+ if (!root || root.nodeClass !== NodeClass.Object) {
112
+ return [];
113
+ }
114
+ // an invalid pattern throws InvalidLikePatternError, which the Method
115
+ // binding turns into Bad_InvalidArgument (clause 6.3.2 Table 4)
116
+ const pattern = new LikePattern(query.pattern, this.likeOptions);
117
+
118
+ const referenceTypeFilter = this.resolveReferenceTypeFilter(query.referenceTypeFilter);
119
+ const entries: AliasEntry[] = [];
120
+
121
+ // Stop one past the cap. The caller only needs to know that the cap was
122
+ // exceeded, so collecting the whole hierarchy first would be wasted work
123
+ // -- and on a Server with a large tag set, a `%` pattern would build the
124
+ // entire result set purely to throw it away with Bad_ResponseTooLarge.
125
+ const collectLimit = query.maxResults === undefined ? Number.POSITIVE_INFINITY : query.maxResults + 1;
126
+
127
+ for (const category of collectCategories(this.addressSpace, root as UAObject)) {
128
+ if (entries.length >= collectLimit) {
129
+ break;
130
+ }
131
+ // Skipped before its aliases are walked, so the cap is spent only on
132
+ // entries the caller may see - and the scan does less work. Only
133
+ // this category is skipped, not its descendants: the gate is
134
+ // per-category, and a denied parent does not imply a denied child.
135
+ if (query.isVisible && !(await query.isVisible(category.nodeId))) {
136
+ continue;
137
+ }
138
+ for (const alias of aliasesOf(this.addressSpace, category)) {
139
+ if (entries.length >= collectLimit) {
140
+ break;
141
+ }
142
+ const aliasName = alias.browseName.name;
143
+ if (!aliasName || !pattern.test(aliasName)) {
144
+ continue;
145
+ }
146
+ const targets = this.targetsOf(alias, referenceTypeFilter);
147
+ if (targets.length === 0) {
148
+ // clause 6.3.2 Table 3: an alias with no Reference of the
149
+ // requested type is simply not a match
150
+ continue;
151
+ }
152
+ entries.push({
153
+ aliasName,
154
+ // the namespace the alias Object was actually published in,
155
+ // not the category's - Aliases and friends live in
156
+ // namespace 0, which clause 6.2 never intends for an alias
157
+ aliasNameNamespaceUri: this.namespaceUriOf(alias.browseName.namespaceIndex),
158
+ referencedNodes: targets.map((t) => t.expandedNodeId),
159
+ // every target is on this Server; aggregating other Servers
160
+ // is out of scope for this package (Annex B / Annex C)
161
+ serverUris: targets.map(() => null),
162
+ categoryNodeId: category.nodeId,
163
+ referenceTypeIds: targets.map((t) => t.referenceTypeId)
164
+ });
165
+ }
166
+ }
167
+ return entries;
168
+ }
169
+
170
+ /** `LastChange` for a category, rolled up from its descendants (clause 6.3.1). */
171
+ public lastChange(categoryNodeId: NodeId): number {
172
+ const root = this.addressSpace.findNode(categoryNodeId);
173
+ if (!root || root.nodeClass !== NodeClass.Object) {
174
+ return this.lastChangeByCategory.get(categoryNodeId.toString()) ?? 0;
175
+ }
176
+ let latest = 0;
177
+ for (const category of collectCategories(this.addressSpace, root as UAObject)) {
178
+ latest = maxVersionTime(latest, this.lastChangeByCategory.get(category.nodeId.toString()) ?? 0);
179
+ }
180
+ return latest;
181
+ }
182
+
183
+ /**
184
+ * Record that a category changed, at `versionTime` (defaulting to now).
185
+ *
186
+ * Only the category itself is stored; the rollup to ancestors happens on
187
+ * read, so a category that is later re-parented reports correctly without
188
+ * anything having to be recomputed.
189
+ */
190
+ public touch(categoryNodeId: NodeId, versionTime?: number): number {
191
+ const value = versionTime ?? nowVersionTime();
192
+ const key = categoryNodeId.toString();
193
+ this.lastChangeByCategory.set(key, maxVersionTime(this.lastChangeByCategory.get(key) ?? 0, value));
194
+ return value;
195
+ }
196
+
197
+ /** Restore persisted `LastChange` values (clause 6.3.1: "shall be persisted"). */
198
+ public restoreLastChange(values: Iterable<readonly [string, number]>): void {
199
+ for (const [key, value] of values) {
200
+ this.lastChangeByCategory.set(key, value);
201
+ }
202
+ }
203
+
204
+ /** Snapshot the per-category `LastChange` values for persistence. */
205
+ public snapshotLastChange(): Array<[string, number]> {
206
+ return [...this.lastChangeByCategory.entries()];
207
+ }
208
+
209
+ /**
210
+ * Add aliases to a category (clause 6.3.4), one StatusCode per entry.
211
+ *
212
+ * The per-item codes of Table 10:
213
+ *
214
+ * - `Bad_NodeIdInvalid` — the NodeId is syntactically unusable.
215
+ * - `Bad_NodeIdUnknown` — the target is on this Server and does not exist.
216
+ * - `Uncertain_ReferenceOutOfServer` — the target is on another Server. The
217
+ * clause is explicit that this is returned **whether or not** a check was
218
+ * performed: *"If the Server does not check for the external Node's
219
+ * existence, it shall return Uncertain_ReferenceOutOfServer."* This Server
220
+ * does not check, because checking means being a Client of the other
221
+ * Server, which is the aggregation these packages exclude by design.
222
+ * - `Bad_NotSupported` — when {@link AddressSpaceAliasStoreOptions.allowRemoteTargets}
223
+ * is off, which Table 10 explicitly permits.
224
+ *
225
+ * An exact duplicate of (AliasName, target, target Server) is `Good` and
226
+ * ignored, whether it was already stored or repeated within this call.
227
+ */
228
+ public add(categoryNodeId: NodeId, entries: AliasEntry[]): StatusCode[] {
229
+ const category = this.addressSpace.findNode(categoryNodeId);
230
+ if (!category || category.nodeClass !== NodeClass.Object) {
231
+ return entries.map(() => StatusCodes.BadNodeIdUnknown);
232
+ }
233
+ const categoryNode = category as UAObject;
234
+
235
+ // duplicates repeated *within* this call are ignored too, so the set
236
+ // has to grow as we go rather than being a snapshot of the start state
237
+ const seenInThisCall = new Set<string>();
238
+
239
+ return entries.map((entry) => {
240
+ const target = entry.referencedNodes[0];
241
+ const serverUri = entry.serverUris[0] ?? null;
242
+
243
+ if (!target) {
244
+ return StatusCodes.BadNodeIdInvalid;
245
+ }
246
+ if (!entry.aliasName) {
247
+ return StatusCodes.BadNodeIdInvalid;
248
+ }
249
+
250
+ const key = `${entry.aliasName}${target.toString()}${serverUri ?? ""}`;
251
+ if (seenInThisCall.has(key)) {
252
+ return StatusCodes.Good;
253
+ }
254
+ seenInThisCall.add(key);
255
+
256
+ // Table 9: the ServerIndex inside the ExpandedNodeId is ignored;
257
+ // TargetServers is authoritative
258
+ if (serverUri !== null) {
259
+ if (!this.allowRemoteTargets) {
260
+ return StatusCodes.BadNotSupported;
261
+ }
262
+ return this.addRemote(categoryNode, entry, target, serverUri);
263
+ }
264
+
265
+ return this.addLocal(categoryNode, entry, target);
266
+ });
267
+ }
268
+
269
+ /**
270
+ * Remove aliases from a category (clause 6.3.5), one StatusCode per entry.
271
+ *
272
+ * `Bad_NotFound` when the name is not in the category, `Bad_InvalidState`
273
+ * when it is there but not owned by this Server — clause 6.3.5 opens by
274
+ * saying a Server "shall only delete AliasName instances that are defined on
275
+ * the Server exposing this Method".
276
+ *
277
+ * An entry with no target removes every target of that name. Removal is
278
+ * all-or-nothing per name: if any target cannot go, none of that name's do.
279
+ */
280
+ public delete(categoryNodeId: NodeId, entries: Pick<AliasEntry, "aliasName" | "referencedNodes">[]): StatusCode[] {
281
+ const category = this.addressSpace.findNode(categoryNodeId);
282
+ if (!category || category.nodeClass !== NodeClass.Object) {
283
+ return entries.map(() => StatusCodes.BadNotFound);
284
+ }
285
+ const categoryNode = category as UAObject;
286
+
287
+ return entries.map((entry) => {
288
+ const alias = findAlias(this.addressSpace, categoryNode, entry.aliasName);
289
+ if (!alias) {
290
+ return StatusCodes.BadNotFound;
291
+ }
292
+ // an alias whose targets all live on other Servers was learned from
293
+ // elsewhere and is not ours to delete
294
+ if (this.isForeign(alias)) {
295
+ return StatusCodes.BadInvalidState;
296
+ }
297
+
298
+ const requested = entry.referencedNodes ?? [];
299
+ if (requested.length === 0) {
300
+ // "all AliasNames with the provided name are deleted"
301
+ removeAlias(this.addressSpace, categoryNode, entry.aliasName);
302
+ return StatusCodes.Good;
303
+ }
304
+
305
+ // all or nothing: check every requested target is present first
306
+ const references = alias.findReferencesEx(ALIAS_FOR, BrowseDirection.Forward);
307
+ const present = new Set(references.map((r) => r.nodeId.toString()));
308
+ const wanted: NodeId[] = [];
309
+ for (const target of requested) {
310
+ const local = toLocalNodeId(this.addressSpace, target);
311
+ if (!local || !present.has(local.toString())) {
312
+ return StatusCodes.BadNotFound;
313
+ }
314
+ wanted.push(local);
315
+ }
316
+ for (const target of wanted) {
317
+ removeAlias(this.addressSpace, categoryNode, entry.aliasName, target);
318
+ }
319
+ return StatusCodes.Good;
320
+ });
321
+ }
322
+
323
+ /** Add an alias whose target is on this Server. */
324
+ private addLocal(category: UAObject, entry: AliasEntry, target: ExpandedNodeId): StatusCode {
325
+ const localNodeId = toLocalNodeId(this.addressSpace, target);
326
+ if (!localNodeId) {
327
+ return StatusCodes.BadNodeIdInvalid;
328
+ }
329
+ if (!this.addressSpace.findNode(localNodeId)) {
330
+ // Table 10: "The TargetNode does not exist in the AliasName Server
331
+ // and the TargetServer is the local server"
332
+ return StatusCodes.BadNodeIdUnknown;
333
+ }
334
+ try {
335
+ addAlias(this.addressSpace, category, entry.aliasName, localNodeId, {
336
+ referenceType: entry.referenceTypeIds[0]
337
+ });
338
+ } catch {
339
+ // a category restriction (clause 9.3 / 9.4) refused the target
340
+ return StatusCodes.BadNodeIdInvalid;
341
+ }
342
+ return StatusCodes.Good;
343
+ }
344
+
345
+ /**
346
+ * Add an alias whose target is on another Server.
347
+ *
348
+ * Always `Uncertain_ReferenceOutOfServer`: this Server does not verify
349
+ * Nodes on other Servers, and clause 6.3.4 says that case returns the
350
+ * uncertain code rather than success.
351
+ */
352
+ private addRemote(category: UAObject, entry: AliasEntry, target: ExpandedNodeId, _serverUri: string): StatusCode {
353
+ try {
354
+ addAlias(this.addressSpace, category, entry.aliasName, target, {
355
+ referenceType: entry.referenceTypeIds[0],
356
+ allowUnresolvedTarget: true
357
+ });
358
+ } catch {
359
+ return StatusCodes.BadNodeIdInvalid;
360
+ }
361
+ return StatusCodes.UncertainReferenceOutOfServer;
362
+ }
363
+
364
+ /** True when none of the alias's targets are on this Server. */
365
+ private isForeign(alias: UAObject): boolean {
366
+ const references = alias.findReferencesEx(ALIAS_FOR, BrowseDirection.Forward);
367
+ if (references.length === 0) {
368
+ return false;
369
+ }
370
+ return references.every((reference) => this.addressSpace.findNode(reference.nodeId) === null);
371
+ }
372
+
373
+ /** The URI of a namespace index, or undefined for namespace 0. */
374
+ private namespaceUriOf(namespaceIndex: number): string | undefined {
375
+ // namespace 0 is the OPC Foundation's and is never a legitimate alias
376
+ // namespace; reporting it would be worse than reporting nothing
377
+ return namespaceIndex === 0 ? undefined : this.addressSpace.getNamespaceUri(namespaceIndex);
378
+ }
379
+
380
+ /**
381
+ * Resolve the `ReferenceTypeFilter` argument.
382
+ *
383
+ * A null or absent NodeId means any ReferenceType (clause 6.3.2 Table 3).
384
+ */
385
+ private resolveReferenceTypeFilter(filter: NodeId | undefined): NodeId | null {
386
+ if (!filter || filter.isEmpty()) {
387
+ return null;
388
+ }
389
+ return filter;
390
+ }
391
+
392
+ /**
393
+ * The Nodes an alias points at.
394
+ *
395
+ * `findReferencesEx` already includes subtypes of the ReferenceType, which
396
+ * is what clause 6.3.2 Table 3 asks for: "Any ReferenceType includes all
397
+ * subtypes of that ReferenceType".
398
+ *
399
+ * An absent filter falls back to `AliasFor` rather than to *every*
400
+ * ReferenceType. Table 3 describes the filter as "AliasFor or one of its
401
+ * subtypes", and clause 8.2 makes `AliasFor` the ReferenceType that links an
402
+ * AliasName to what it names; taking "any" literally would return the
403
+ * alias's `HasTypeDefinition` target and its Organizes back-reference, which
404
+ * are not things the alias names.
405
+ */
406
+ private targetsOf(
407
+ alias: UAObject,
408
+ referenceTypeFilter: NodeId | null
409
+ ): Array<{ expandedNodeId: ExpandedNodeId; referenceTypeId: NodeId }> {
410
+ const referenceType = referenceTypeFilter ?? ALIAS_FOR;
411
+ const references = alias.findReferencesEx(referenceType, BrowseDirection.Forward);
412
+ return references.map((reference) => ({
413
+ expandedNodeId: toExpandedNodeId(this.addressSpace, reference.nodeId),
414
+ referenceTypeId: reference.referenceType
415
+ }));
416
+ }
417
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @module node-opcua-alias-name-server
3
+ *
4
+ * Walking the AliasName hierarchy in the address space (OPC 10000-17
5
+ * clause 6.3.1).
6
+ */
7
+
8
+ import type { BaseNode, IAddressSpace, UAObject, UAObjectType } from "node-opcua-address-space-base";
9
+ import { BrowseDirection, NodeClass } from "node-opcua-data-model";
10
+ import type { NodeId } from "node-opcua-nodeid";
11
+ import { ALIAS_NAME_CATEGORY_TYPE, ALIAS_NAME_TYPE, WellKnownCategories } from "./well_known.js";
12
+
13
+ /** True when `node` is an Object whose TypeDefinition is `type` or a subtype. */
14
+ function isInstanceOf(node: BaseNode, type: UAObjectType | null): boolean {
15
+ if (!type || node.nodeClass !== NodeClass.Object) {
16
+ return false;
17
+ }
18
+ const typeDefinition = (node as UAObject).typeDefinitionObj;
19
+ if (!typeDefinition) {
20
+ return false;
21
+ }
22
+ return typeDefinition.nodeId.value === type.nodeId.value || typeDefinition.isSubtypeOf(type);
23
+ }
24
+
25
+ /** Resolve `AliasNameCategoryType`, or null on an address space without Part 17. */
26
+ export function findAliasNameCategoryType(addressSpace: IAddressSpace): UAObjectType | null {
27
+ return addressSpace.findObjectType(ALIAS_NAME_CATEGORY_TYPE);
28
+ }
29
+
30
+ /** Resolve `AliasNameType`, or null on an address space without Part 17. */
31
+ export function findAliasNameType(addressSpace: IAddressSpace): UAObjectType | null {
32
+ return addressSpace.findObjectType(ALIAS_NAME_TYPE);
33
+ }
34
+
35
+ /** True when `node` is an instance of `AliasNameCategoryType` (or a subtype). */
36
+ export function isAliasNameCategory(addressSpace: IAddressSpace, node: BaseNode): boolean {
37
+ return isInstanceOf(node, findAliasNameCategoryType(addressSpace));
38
+ }
39
+
40
+ /** True when `node` is an instance of `AliasNameType` (or a subtype). */
41
+ export function isAliasName(addressSpace: IAddressSpace, node: BaseNode): boolean {
42
+ return isInstanceOf(node, findAliasNameType(addressSpace));
43
+ }
44
+
45
+ /**
46
+ * The Organized children of a category.
47
+ *
48
+ * `findReferencesExAsObject` follows subtypes of the ReferenceType, so a vendor
49
+ * subtype of `Organizes` is included, and `HierarchicalReferences` is used as
50
+ * the base so a Server that nests categories with `HasComponent` is not missed.
51
+ */
52
+ function organizedChildren(category: UAObject): BaseNode[] {
53
+ return category.findReferencesExAsObject("HierarchicalReferences", BrowseDirection.Forward);
54
+ }
55
+
56
+ /**
57
+ * Every `AliasNameCategoryType` instance at or below `root`, depth first.
58
+ *
59
+ * A category may legitimately be reached more than once — clause 6.3.1 allows an
60
+ * `<Alias>` (and by extension a subtree) to appear in more than one place — so
61
+ * `seen` both de-duplicates and makes a cyclic hierarchy terminate rather than
62
+ * hang the Method call.
63
+ */
64
+ export function collectCategories(addressSpace: IAddressSpace, root: UAObject): UAObject[] {
65
+ const result: UAObject[] = [];
66
+ const seen = new Set<string>();
67
+
68
+ const visit = (category: UAObject): void => {
69
+ const key = category.nodeId.toString();
70
+ if (seen.has(key)) {
71
+ return;
72
+ }
73
+ seen.add(key);
74
+ result.push(category);
75
+ for (const child of organizedChildren(category)) {
76
+ if (isAliasNameCategory(addressSpace, child)) {
77
+ visit(child as UAObject);
78
+ }
79
+ }
80
+ };
81
+
82
+ visit(root);
83
+ return result;
84
+ }
85
+
86
+ /** The `AliasNameType` instances Organized directly by `category`. */
87
+ export function aliasesOf(addressSpace: IAddressSpace, category: UAObject): UAObject[] {
88
+ const result: UAObject[] = [];
89
+ for (const child of organizedChildren(category)) {
90
+ if (isAliasName(addressSpace, child)) {
91
+ result.push(child as UAObject);
92
+ }
93
+ }
94
+ return result;
95
+ }
96
+
97
+ /**
98
+ * Every `AliasNameCategoryType` instance reachable from `Aliases`, plus any
99
+ * roots named explicitly.
100
+ *
101
+ * Discovery walks the hierarchy below `Aliases` rather than sweeping the whole
102
+ * address space, because that is where clause 9.1 puts categories: vendors "are
103
+ * free to add additional instances of AliasNameCategoryType under this
104
+ * hierarchy". There is also no way to sweep — the address space keeps no inverse
105
+ * `HasTypeDefinition` reference from an ObjectType to its instances, so a type
106
+ * cannot be asked for them.
107
+ *
108
+ * `additionalRoots` is the escape hatch for a Server that models a category
109
+ * somewhere else: without it that category's MANDATORY `FindAlias` would stay
110
+ * unbound, which is the very defect this package exists to fix.
111
+ */
112
+ export function collectAllCategories(addressSpace: IAddressSpace, additionalRoots?: Array<NodeId | UAObject>): UAObject[] {
113
+ const byId = new Map<string, UAObject>();
114
+
115
+ const roots: BaseNode[] = [];
116
+ const aliasesRoot = addressSpace.findNode(WellKnownCategories.Aliases);
117
+ if (aliasesRoot) {
118
+ roots.push(aliasesRoot);
119
+ }
120
+ for (const extra of additionalRoots ?? []) {
121
+ const node = "nodeClass" in extra ? extra : addressSpace.findNode(extra);
122
+ if (node) {
123
+ roots.push(node);
124
+ }
125
+ }
126
+
127
+ for (const root of roots) {
128
+ if (root.nodeClass !== NodeClass.Object) {
129
+ continue;
130
+ }
131
+ for (const category of collectCategories(addressSpace, root as UAObject)) {
132
+ byId.set(category.nodeId.toString(), category);
133
+ }
134
+ }
135
+ return [...byId.values()];
136
+ }
137
+
138
+ /** The NodeIds of the well-known categories present in this address space. */
139
+ export function presentWellKnownCategories(addressSpace: IAddressSpace): NodeId[] {
140
+ return Object.values(WellKnownCategories).filter((nodeId) => addressSpace.findNode(nodeId) !== null);
141
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @module node-opcua-alias-name-server
3
+ *
4
+ * A per-category index from AliasName to the `AliasNameType` Object that carries
5
+ * it.
6
+ *
7
+ * ## Why this exists
8
+ *
9
+ * Looking an alias up by name means finding a child of the category with a given
10
+ * BrowseName. The address space has an O(1) index for exactly that — but
11
+ * `getChildByName` only consults it for `HasChild` subtypes, and an alias is an
12
+ * `Organizes` child of its category (clause 6.3 Table 2). `getFolderElementByName`
13
+ * does cover `Organizes`, but scans. So neither route is both correct and fast.
14
+ *
15
+ * That matters because `addAlias` has to look for an existing alias of the same
16
+ * name before creating one, so a Server building N aliases performed N linear
17
+ * scans of a growing category — quadratic. Measured on a category holding 1500
18
+ * aliases, 200 lookups cost 97 ms by scanning and 0 ms through this index, and
19
+ * building 1500 aliases went from 2545 ms to 1686 ms.
20
+ *
21
+ * That is not the whole story: the larger remaining cost is inside
22
+ * `UAObjectType.instantiate`, which is itself superlinear in the number of
23
+ * children the parent already has. That is an address-space concern rather than
24
+ * an AliasName one, and is tracked separately.
25
+ *
26
+ * ## How it stays correct
27
+ *
28
+ * The index is built lazily, from one full scan, so aliases modelled in a
29
+ * NodeSet2.xml are picked up. After that it is maintained incrementally by
30
+ * {@link noteAliasAdded} and {@link noteAliasRemoved}, which `addAlias` and
31
+ * `removeAlias` call.
32
+ *
33
+ * A hit is verified against the address space before being returned, so an alias
34
+ * deleted by other means degrades to a miss rather than a dangling Object. A
35
+ * miss is trusted: an alias created behind this package's back after the index
36
+ * was built would not be found, and `addAlias` would then fail loudly on the
37
+ * duplicate BrowseName rather than corrupting anything. Call
38
+ * {@link invalidateAliasIndex} if a Server mutates a category by other means.
39
+ *
40
+ * Keyed by the node itself in a `WeakMap`, so a disposed address space takes its
41
+ * indexes with it.
42
+ */
43
+
44
+ import type { IAddressSpace, UAObject } from "node-opcua-address-space-base";
45
+ import { BrowseDirection, NodeClass } from "node-opcua-data-model";
46
+ import type { NodeId } from "node-opcua-nodeid";
47
+ import { findAliasNameType } from "./alias_hierarchy.js";
48
+
49
+ /** AliasName (string part only, per clause 6.2) to the Object's NodeId. */
50
+ type AliasIndex = Map<string, NodeId>;
51
+
52
+ const indexes = new WeakMap<UAObject, AliasIndex>();
53
+
54
+ /** Build the index for a category by scanning it once. */
55
+ function buildIndex(addressSpace: IAddressSpace, category: UAObject): AliasIndex {
56
+ const index: AliasIndex = new Map();
57
+ const aliasNameType = findAliasNameType(addressSpace);
58
+ if (!aliasNameType) {
59
+ return index;
60
+ }
61
+ for (const child of category.findReferencesExAsObject("HierarchicalReferences", BrowseDirection.Forward)) {
62
+ if (child.nodeClass !== NodeClass.Object) {
63
+ continue;
64
+ }
65
+ const name = child.browseName.name;
66
+ if (!name || index.has(name)) {
67
+ continue;
68
+ }
69
+ const typeDefinition = (child as UAObject).typeDefinitionObj;
70
+ if (
71
+ typeDefinition &&
72
+ (typeDefinition.nodeId.value === aliasNameType.nodeId.value || typeDefinition.isSubtypeOf(aliasNameType))
73
+ ) {
74
+ index.set(name, child.nodeId);
75
+ }
76
+ }
77
+ return index;
78
+ }
79
+
80
+ /** The index for a category, built on first use. */
81
+ function indexOf(addressSpace: IAddressSpace, category: UAObject): AliasIndex {
82
+ let index = indexes.get(category);
83
+ if (!index) {
84
+ index = buildIndex(addressSpace, category);
85
+ indexes.set(category, index);
86
+ }
87
+ return index;
88
+ }
89
+
90
+ /**
91
+ * The `AliasNameType` instance with this name in `category`, or null.
92
+ *
93
+ * O(1) after the first call on a given category.
94
+ */
95
+ export function lookupAlias(addressSpace: IAddressSpace, category: UAObject, aliasName: string): UAObject | null {
96
+ const index = indexOf(addressSpace, category);
97
+ const nodeId = index.get(aliasName);
98
+ if (!nodeId) {
99
+ return null;
100
+ }
101
+ const node = addressSpace.findNode(nodeId);
102
+ if (!node || node.nodeClass !== NodeClass.Object) {
103
+ // deleted behind our back; forget it rather than hand back a ghost
104
+ index.delete(aliasName);
105
+ return null;
106
+ }
107
+ return node as UAObject;
108
+ }
109
+
110
+ /** Record a newly created alias. */
111
+ export function noteAliasAdded(addressSpace: IAddressSpace, category: UAObject, aliasName: string, nodeId: NodeId): void {
112
+ indexOf(addressSpace, category).set(aliasName, nodeId);
113
+ }
114
+
115
+ /** Record a removed alias. */
116
+ export function noteAliasRemoved(addressSpace: IAddressSpace, category: UAObject, aliasName: string): void {
117
+ indexOf(addressSpace, category).delete(aliasName);
118
+ }
119
+
120
+ /**
121
+ * Forget a category's index, so it is rebuilt from the address space on next
122
+ * use. Needed only if a Server adds or removes aliases without going through
123
+ * {@link addAlias} / {@link removeAlias}.
124
+ */
125
+ export function invalidateAliasIndex(category: UAObject): void {
126
+ indexes.delete(category);
127
+ }