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,342 @@
1
+ /**
2
+ * @module node-opcua-alias-name-server
3
+ *
4
+ * Install OPC 10000-17 AliasName support on a Server.
5
+ *
6
+ * Every node-opcua Server that loads the standard nodeset already exposes
7
+ * `Aliases`, `TagVariables` and `Topics`, each carrying a MANDATORY `FindAlias`
8
+ * Method that is bound to nothing. A conformance tester therefore sees the SDK
9
+ * advertise the AliasName feature and then fail its only required Method. This
10
+ * binds them.
11
+ */
12
+
13
+ import type { IAddressSpace, ISessionContext, UAObject } from "node-opcua-address-space-base";
14
+ import type { IAliasStore, LikeOptions } from "node-opcua-alias-name-common";
15
+ import type { NodeId } from "node-opcua-nodeid";
16
+ import { AddressSpaceAliasStore } from "./address_space_alias_store.js";
17
+ import { collectAllCategories } from "./alias_hierarchy.js";
18
+ import {
19
+ type BindAliasCategoryOptions,
20
+ bindAliasCategory,
21
+ getInstalledAliasNames,
22
+ setInstalledAliasNames
23
+ } from "./bind_alias_category.js";
24
+ import type { AliasComparator } from "./bind_find_alias.js";
25
+ import { LastChangeTracker } from "./last_change.js";
26
+ import { DEFAULT_MAX_RESULTS, WellKnownCategories } from "./well_known.js";
27
+
28
+ export { DEFAULT_MAX_RESULTS };
29
+
30
+ /**
31
+ * Supplies the `AliasNameCategoryType` instances to bind.
32
+ *
33
+ * Defaults to {@link defaultCategoryProvider}, which walks down from `Aliases`.
34
+ * Replace it when the category set is not a static tree — one category per
35
+ * customer, one per upstream Server, categories that appear and disappear at
36
+ * runtime. May be asynchronous, since the set may have to be fetched.
37
+ *
38
+ * A provider only decides *what installation binds*. Categories created after
39
+ * installation are bound with {@link bindAliasCategory}, or created already
40
+ * bound with {@link addAliasCategory}.
41
+ */
42
+ export type CategoryProvider = (addressSpace: IAddressSpace) => UAObject[] | Promise<UAObject[]>;
43
+
44
+ /**
45
+ * The default provider: every `AliasNameCategoryType` instance at or below
46
+ * `Aliases`, plus any roots named in `additionalCategoryRoots`.
47
+ *
48
+ * `additionalCategoryRoots` is expressed through this rather than as a parallel
49
+ * mechanism, so a custom provider can reuse it — `defaultCategoryProvider(roots)`
50
+ * composes with whatever else the Server knows about.
51
+ */
52
+ export function defaultCategoryProvider(additionalRoots?: Array<NodeId | UAObject>): CategoryProvider {
53
+ return (addressSpace: IAddressSpace) => collectAllCategories(addressSpace, additionalRoots);
54
+ }
55
+
56
+ export interface InstallAliasNamesOptions {
57
+ /**
58
+ * Where aliases come from. Defaults to an {@link AddressSpaceAliasStore},
59
+ * which reads them straight out of the address space — so a Server whose
60
+ * NodeSet2.xml already models `AliasNameType` instances needs no options at
61
+ * all.
62
+ */
63
+ store?: IAliasStore;
64
+ /** Result cap per call (clause 6.3.2 Table 4). Defaults to {@link DEFAULT_MAX_RESULTS}. */
65
+ maxResults?: number;
66
+ /**
67
+ * Also bind `FindAliasVerbose` (clause 6.3.3, conformance unit
68
+ * AliasName FindAliasVerbose). On by default: it costs one extra Method per
69
+ * category and is what lets a Client see which category held a hit.
70
+ */
71
+ verbose?: boolean;
72
+ /**
73
+ * Also expose `AddAliasesToCategory` and `DeleteAliasesFromCategory`
74
+ * (clauses 6.3.4 and 6.3.5, conformance unit *AliasName Configuration
75
+ * Support*, CU 5874).
76
+ *
77
+ * **Off by default**: both Methods are Optional, and a write surface that
78
+ * appears without being asked for is a surface nobody reviewed. Turning it
79
+ * on is not by itself dangerous — every call is denied until
80
+ * {@link isWriteAllowed} says otherwise — but the Methods do become visible
81
+ * to a browsing Client.
82
+ */
83
+ configurationMethods?: boolean;
84
+ /** Passed to the `Like` matcher used by the default store. */
85
+ likeOptions?: LikeOptions;
86
+ /** Result ordering (clause 6.3.2, "best match first"). */
87
+ comparator?: AliasComparator;
88
+ /**
89
+ * Read gate, consulted **per category** and allowed to be asynchronous.
90
+ * Defaults to allowing everyone, so a Server that simply publishes its
91
+ * aliases is unaffected.
92
+ *
93
+ * A direct call on a denied category answers `Bad_UserAccessDenied`; a
94
+ * denied category reached by a recursive search is omitted and the call
95
+ * still returns `Good`, so nothing reveals that it exists.
96
+ *
97
+ * OPC 10000-17 defines no security model — four `Bad_UserAccessDenied` rows,
98
+ * no Security clause, no Roles, and no `RolePermissions` on any Part 17 node
99
+ * in the standard nodeset — so every Server has to supply its own rule.
100
+ */
101
+ isReadAllowed?: (context: ISessionContext, categoryNodeId: NodeId) => boolean | Promise<boolean>;
102
+ /**
103
+ * Write gate for the configuration Methods, mirroring
104
+ * {@link isReadAllowed}. **Defaults to denying everyone**: the write surface
105
+ * is the one place where a permissive default would be a security defect
106
+ * rather than a convenience.
107
+ */
108
+ isWriteAllowed?: (context: ISessionContext, categoryNodeId: NodeId) => boolean | Promise<boolean>;
109
+ /**
110
+ * File backing the persisted `LastChange` values (clause 6.3.1: *"The
111
+ * LastChange shall be persisted"*).
112
+ *
113
+ * Without it, every restart resets `LastChange` to zero — and a Client that
114
+ * sees a value older than the one it cached is required by clause 6.3.1 to
115
+ * clear its cache. So an unpersisted Server silently orders every connected
116
+ * Client to discard a still-valid cache on every restart. Set this on any
117
+ * Server that Clients cache against.
118
+ *
119
+ * The file is small JSON: a version and a map of category NodeId to
120
+ * VersionTime. Writes are atomic.
121
+ */
122
+ persistencePath?: string;
123
+ /**
124
+ * Add a `LastChange` Property to every category, not only the `Aliases` root.
125
+ *
126
+ * On by default. `LastChange` is Optional on `AliasNameCategoryType` and the
127
+ * shipped nodeset instantiates it only on the root, which clause 9.2 makes
128
+ * mandatory — but the clause 6.3.1 rollup is only observable where the
129
+ * Property exists, so a Client watching one branch needs it there. Turn it
130
+ * off to keep the address space exactly as the nodeset ships it.
131
+ */
132
+ lastChangeOnAllCategories?: boolean;
133
+ /**
134
+ * Supplies "now" as a VersionTime, for tests that need to pin it. A
135
+ * VersionTime has one-second resolution, which is otherwise awkward to
136
+ * assert against.
137
+ */
138
+ nowVersionTime?: () => number;
139
+ /**
140
+ * Extra roots to search for `AliasNameCategoryType` instances.
141
+ *
142
+ * Categories are discovered by walking down from `Aliases`, which is where
143
+ * clause 9.1 puts them. Name a category here if the Server models one
144
+ * outside that hierarchy, otherwise its MANDATORY `FindAlias` stays unbound.
145
+ *
146
+ * Ignored when {@link categoryProvider} is supplied — compose it in with
147
+ * {@link defaultCategoryProvider} instead of passing both.
148
+ */
149
+ additionalCategoryRoots?: Array<NodeId | UAObject>;
150
+ /**
151
+ * Replace category discovery entirely. See {@link CategoryProvider}.
152
+ */
153
+ categoryProvider?: CategoryProvider;
154
+ /**
155
+ * Declare the `ALIAS` ServerCapability (OPC 10000-12 Annex D Table D.1).
156
+ * **On by default.**
157
+ *
158
+ * A Server that does not advertise it is never discovered by anything
159
+ * looking for alias-capable Servers, and nothing reports the failure — so
160
+ * leaving it to each caller to remember means it will sometimes be
161
+ * forgotten, invisibly. Installing the feature and declaring it are the same
162
+ * decision, so they happen together.
163
+ *
164
+ * **Call `installAliasNames` before `server.start()`.** The address space
165
+ * exists from `initialize()` onwards, and mDNS/LDS registration reads the
166
+ * capability list during `start()` — afterwards the list is still correct
167
+ * for anything reading `ServerConfiguration`, but the registration has
168
+ * already gone out without it.
169
+ *
170
+ * Set false if the Server manages its own capability list.
171
+ */
172
+ advertiseCapability?: boolean;
173
+ }
174
+
175
+ export interface InstallAliasNamesResult {
176
+ /** The store that was used — the injected one, or the default. */
177
+ store: IAliasStore;
178
+ /** Every `AliasNameCategoryType` instance that had its Methods bound. */
179
+ categories: NodeId[];
180
+ /** True when this call did the work; false when AliasNames were already installed. */
181
+ installed: boolean;
182
+ /**
183
+ * Keeps `LastChange` correct across the hierarchy, and persists it
184
+ * (clause 6.3.1).
185
+ */
186
+ lastChange?: LastChangeTracker;
187
+ /**
188
+ * The options every category was bound with.
189
+ *
190
+ * Pass these to {@link bindAliasCategory} to bind a category created later
191
+ * exactly as the installed ones were bound, without reassembling them by
192
+ * hand and risking a different store or result cap.
193
+ */
194
+ bindingOptions: BindAliasCategoryOptions;
195
+ }
196
+
197
+ /** The server-like object we need: the address space, and the capability list. */
198
+ export interface IServerForAliasNames {
199
+ engine: {
200
+ addressSpace: IAddressSpace | null;
201
+ };
202
+ /**
203
+ * The Server's OPC 10000-12 Annex D capability identifiers — on an
204
+ * `OPCUAServer` this is `capabilitiesForMDNS`. Optional so a caller can pass
205
+ * anything address-space-shaped; when present, `ALIAS` is added to it.
206
+ */
207
+ capabilitiesForMDNS?: string[];
208
+ }
209
+
210
+ /**
211
+ * The `ALIAS` ServerCapability identifier (OPC 10000-12 Annex D Table D.1).
212
+ *
213
+ * Part 17's prose writes it `Alias`; Part 12 Annex D is the normative source and
214
+ * writes `ALIAS`, matched case-insensitively.
215
+ */
216
+ export const ALIAS_SERVER_CAPABILITY_ID = "ALIAS";
217
+
218
+ /** The placeholder node-opcua uses for "no capabilities declared". */
219
+ const NO_CAPABILITY_PLACEHOLDER = "NA";
220
+
221
+ /**
222
+ * Add `ALIAS` to a Server's Annex D capability list, in place.
223
+ *
224
+ * Idempotent, case-insensitive, and replaces the `NA` placeholder rather than
225
+ * producing the meaningless `["NA", "ALIAS"]`.
226
+ *
227
+ * @returns true when the list was changed.
228
+ */
229
+ export function advertiseAliasCapability(capabilities: string[]): boolean {
230
+ if (capabilities.some((c) => c.toUpperCase() === ALIAS_SERVER_CAPABILITY_ID)) {
231
+ return false;
232
+ }
233
+ // "NA" means "none"; it cannot coexist with a real capability
234
+ const placeholderIndex = capabilities.findIndex((c) => c.toUpperCase() === NO_CAPABILITY_PLACEHOLDER);
235
+ if (placeholderIndex >= 0) {
236
+ capabilities.splice(placeholderIndex, 1);
237
+ }
238
+ capabilities.push(ALIAS_SERVER_CAPABILITY_ID);
239
+ return true;
240
+ }
241
+
242
+ /**
243
+ * Install AliasName support on a Server.
244
+ *
245
+ * Call it **between `initialize()` and `start()`**: the address space exists
246
+ * from `initialize()`, and the `ALIAS` capability has to be in place before
247
+ * `start()` performs the mDNS/LDS registration that reads it.
248
+ *
249
+ * ```ts
250
+ * await server.initialize();
251
+ * await installAliasNames(server);
252
+ * await server.start();
253
+ * ```
254
+ */
255
+ export async function installAliasNames(
256
+ server: IServerForAliasNames,
257
+ options?: InstallAliasNamesOptions
258
+ ): Promise<InstallAliasNamesResult> {
259
+ const addressSpace = server.engine.addressSpace;
260
+ if (!addressSpace) {
261
+ throw new Error("installAliasNames: address space is not available. Call this after server.initialize().");
262
+ }
263
+
264
+ const result = await installAliasNamesOnAddressSpace(addressSpace, options);
265
+
266
+ // Declaring the capability is part of installing the feature, not a separate
267
+ // thing to remember: a Server that omits it is never discovered, silently.
268
+ if ((options?.advertiseCapability ?? true) && server.capabilitiesForMDNS) {
269
+ advertiseAliasCapability(server.capabilitiesForMDNS);
270
+ }
271
+ return result;
272
+ }
273
+
274
+ /**
275
+ * Install AliasName support directly on an address space.
276
+ *
277
+ * The two-tier form used elsewhere in the SDK: this one needs no Server, so it
278
+ * can be driven from a test or from a tool that only has an address space.
279
+ */
280
+ export async function installAliasNamesOnAddressSpace(
281
+ addressSpace: IAddressSpace,
282
+ options?: InstallAliasNamesOptions
283
+ ): Promise<InstallAliasNamesResult> {
284
+ const already = getInstalledAliasNames(addressSpace);
285
+ if (already) {
286
+ return { ...already, installed: false };
287
+ }
288
+
289
+ const aliasesRoot = addressSpace.findNode(WellKnownCategories.Aliases);
290
+ if (!aliasesRoot) {
291
+ throw new Error(
292
+ "installAliasNames: the Aliases Object (i=23470) is not in the address space. " +
293
+ "Load the standard nodeset (Opc.Ua.NodeSet2.xml) first."
294
+ );
295
+ }
296
+
297
+ const store = options?.store ?? new AddressSpaceAliasStore(addressSpace, { likeOptions: options?.likeOptions });
298
+
299
+ const lastChange = new LastChangeTracker(addressSpace, {
300
+ persistencePath: options?.persistencePath,
301
+ now: options?.nowVersionTime
302
+ });
303
+
304
+ const bindingOptions: BindAliasCategoryOptions = {
305
+ store,
306
+ maxResults: options?.maxResults ?? DEFAULT_MAX_RESULTS,
307
+ verbose: options?.verbose ?? true,
308
+ comparator: options?.comparator,
309
+ isReadAllowed: options?.isReadAllowed,
310
+ isWriteAllowed: options?.isWriteAllowed,
311
+ lastChangeProperty: options?.lastChangeOnAllCategories ?? true,
312
+ configurationMethods: options?.configurationMethods ?? false,
313
+ onChanged: (categoryNodeId: NodeId) => lastChange.touch(categoryNodeId).then(() => undefined)
314
+ };
315
+
316
+ const provider = options?.categoryProvider ?? defaultCategoryProvider(options?.additionalCategoryRoots);
317
+ const categories = await provider(addressSpace);
318
+
319
+ // one binding path, shared with bindAliasCategory, so a category created
320
+ // after installation cannot end up bound differently
321
+ for (const category of categories) {
322
+ bindAliasCategory(addressSpace, category, bindingOptions);
323
+ }
324
+
325
+ const installed = {
326
+ store,
327
+ categories: categories.map((c) => c.nodeId),
328
+ bindingOptions,
329
+ lastChange
330
+ };
331
+ // Recorded before restore, so anything the restore triggers can already find
332
+ // the tracker on the address space.
333
+ setInstalledAliasNames(addressSpace, installed);
334
+
335
+ // Bring persisted values back and publish them, including the zeros for
336
+ // categories that have never changed - a Property with no value at all reads
337
+ // as Bad_WaitingForInitialData rather than "nothing has happened yet".
338
+ await lastChange.restore();
339
+ lastChange.publishAll(installed.categories);
340
+
341
+ return { ...installed, installed: true };
342
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * @module node-opcua-alias-name-server
3
+ *
4
+ * `LastChange` (OPC 10000-17 clause 6.3.1).
5
+ *
6
+ * A `VersionTime` — **a UInt32 count of seconds since 2000-01-01T00:00:00Z, not
7
+ * a DateTime**. Every other "last changed" Property in the SDK is a DateTime and
8
+ * the name gives no hint, which is what makes this the easiest thing here to get
9
+ * wrong.
10
+ *
11
+ * Clause 6.3.1 lists three things that move it:
12
+ *
13
+ * - an AliasName was added to or deleted from the category,
14
+ * - an AliasNameCategory was added or deleted,
15
+ * - the referenced Nodes of an AliasName in the category changed.
16
+ *
17
+ * and one rule that makes it a tree rather than a set of independent counters:
18
+ * *"For AliasNameCategoryType instances that are nested, the value of LastChange
19
+ * shall always be the latest VersionTime of all Organized AliasNames and
20
+ * AliasNameCategories."* So a change deep in the hierarchy moves every ancestor
21
+ * up to the root.
22
+ *
23
+ * ## One second of resolution
24
+ *
25
+ * Two changes inside the same second are indistinguishable — the value simply
26
+ * does not move. A Client must therefore treat an **equal** `LastChange` as
27
+ * "re-browse to be sure" rather than "nothing changed"; only a value *older*
28
+ * than the cached one carries the strong meaning clause 6.3.1 gives it, namely
29
+ * clear the cache. This is a property of `VersionTime`, not of this
30
+ * implementation, and there is nothing a Server can do about it.
31
+ */
32
+
33
+ import type { BaseNode, IAddressSpace, UAObject, UAVariable } from "node-opcua-address-space-base";
34
+ import { maxVersionTime, nowVersionTime } from "node-opcua-alias-name-common";
35
+ import { BrowseDirection, NodeClass } from "node-opcua-data-model";
36
+ import type { NodeId } from "node-opcua-nodeid";
37
+ import { DataType } from "node-opcua-variant";
38
+ import {
39
+ ALIAS_NAME_ARCHIVE_VERSION,
40
+ type AliasNameArchive,
41
+ readAliasNameArchive,
42
+ writeAliasNameArchive
43
+ } from "./alias_name_archive.js";
44
+ import { WellKnownCategories } from "./well_known.js";
45
+
46
+ /** BrowseName of the Property, per clause 6.3.1 Table 2. */
47
+ export const LAST_CHANGE_BROWSE_NAME = "LastChange";
48
+
49
+ export interface LastChangeTrackerOptions {
50
+ /** File backing the persisted values. Omit to keep them in memory only. */
51
+ persistencePath?: string;
52
+ /**
53
+ * Supplies "now" as a VersionTime. Injectable so a test can pin it; a
54
+ * one-second resolution is otherwise painful to assert against.
55
+ */
56
+ now?: () => number;
57
+ }
58
+
59
+ /**
60
+ * Keeps every category's `LastChange` Property correct, including the rollup to
61
+ * ancestors, and persists the values across restart.
62
+ */
63
+ export class LastChangeTracker {
64
+ private readonly addressSpace: IAddressSpace;
65
+ private readonly persistencePath?: string;
66
+ private readonly now: () => number;
67
+ /** Category NodeId string to VersionTime. */
68
+ private readonly values = new Map<string, number>();
69
+ /** Serialises saves so two rapid changes cannot interleave their writes. */
70
+ private savingChain: Promise<void> = Promise.resolve();
71
+
72
+ constructor(addressSpace: IAddressSpace, options?: LastChangeTrackerOptions) {
73
+ this.addressSpace = addressSpace;
74
+ this.persistencePath = options?.persistencePath;
75
+ this.now = options?.now ?? nowVersionTime;
76
+ }
77
+
78
+ /** The `VersionTime` currently recorded for a category. */
79
+ public get(categoryNodeId: NodeId): number {
80
+ return this.values.get(categoryNodeId.toString()) ?? 0;
81
+ }
82
+
83
+ /**
84
+ * Record that a category changed, and roll the change up to every ancestor.
85
+ *
86
+ * Ancestors are walked at write time rather than computed at read time, so
87
+ * the Property a Client subscribes to actually carries the value — a
88
+ * rollup that existed only inside the Server would be invisible on the
89
+ * wire, which is the whole point of the Property.
90
+ */
91
+ public async touch(categoryNodeId: NodeId, versionTime?: number): Promise<number> {
92
+ const value = versionTime ?? this.now();
93
+ for (const nodeId of this.selfAndAncestors(categoryNodeId)) {
94
+ const key = nodeId.toString();
95
+ this.values.set(key, maxVersionTime(this.values.get(key) ?? 0, value));
96
+ this.writeProperty(nodeId);
97
+ }
98
+ await this.save();
99
+ return value;
100
+ }
101
+
102
+ /** Restore persisted values and publish them to the address space. */
103
+ public async restore(): Promise<void> {
104
+ if (this.persistencePath) {
105
+ const archive = await readAliasNameArchive(this.persistencePath);
106
+ if (archive) {
107
+ for (const [key, value] of Object.entries(archive.lastChange)) {
108
+ this.values.set(key, value);
109
+ }
110
+ }
111
+ }
112
+ for (const key of this.values.keys()) {
113
+ const node = this.addressSpace.findNode(key);
114
+ if (node) {
115
+ this.writeProperty(node.nodeId);
116
+ }
117
+ }
118
+ }
119
+
120
+ /** Publish the current value of every known category, including zeros. */
121
+ public publishAll(categoryNodeIds: NodeId[]): void {
122
+ for (const nodeId of categoryNodeIds) {
123
+ this.writeProperty(nodeId);
124
+ }
125
+ }
126
+
127
+ /** Snapshot for persistence or inspection. */
128
+ public snapshot(): Record<string, number> {
129
+ return Object.fromEntries(this.values.entries());
130
+ }
131
+
132
+ /** Persist, if a path was given. Saves are serialised, never concurrent. */
133
+ public async save(): Promise<void> {
134
+ if (!this.persistencePath) {
135
+ return;
136
+ }
137
+ const path = this.persistencePath;
138
+ const archive: AliasNameArchive = { version: ALIAS_NAME_ARCHIVE_VERSION, lastChange: this.snapshot() };
139
+ this.savingChain = this.savingChain.then(() => writeAliasNameArchive(path, archive));
140
+ await this.savingChain;
141
+ }
142
+
143
+ /** Write the value onto the category's `LastChange` Property, if it has one. */
144
+ private writeProperty(categoryNodeId: NodeId): void {
145
+ const node = this.addressSpace.findNode(categoryNodeId);
146
+ if (!node || node.nodeClass !== NodeClass.Object) {
147
+ return;
148
+ }
149
+ const property = (node as UAObject).getPropertyByName(LAST_CHANGE_BROWSE_NAME);
150
+ if (!property) {
151
+ // LastChange is Optional on AliasNameCategoryType; a category
152
+ // without one simply does not publish its version
153
+ return;
154
+ }
155
+ // VersionTime (i=20998) is a UInt32 subtype - not a DateTime
156
+ (property as UAVariable).setValueFromSource({
157
+ dataType: DataType.UInt32,
158
+ value: this.values.get(categoryNodeId.toString()) ?? 0
159
+ });
160
+ }
161
+
162
+ /**
163
+ * The category itself, then every `AliasNameCategoryType` ancestor up to and
164
+ * including the `Aliases` root.
165
+ *
166
+ * Cycle-safe: an address space that nests categories in a loop would
167
+ * otherwise hang the Method call that triggered the change.
168
+ */
169
+ private selfAndAncestors(categoryNodeId: NodeId): NodeId[] {
170
+ const result: NodeId[] = [];
171
+ const seen = new Set<string>();
172
+
173
+ const visit = (nodeId: NodeId): void => {
174
+ const key = nodeId.toString();
175
+ if (seen.has(key)) {
176
+ return;
177
+ }
178
+ seen.add(key);
179
+ result.push(nodeId);
180
+
181
+ const node = this.addressSpace.findNode(nodeId);
182
+ if (!node) {
183
+ return;
184
+ }
185
+ for (const parent of (node as BaseNode).findReferencesExAsObject("HierarchicalReferences", BrowseDirection.Inverse)) {
186
+ if (parent.nodeClass === NodeClass.Object) {
187
+ visit(parent.nodeId);
188
+ }
189
+ }
190
+ };
191
+
192
+ visit(categoryNodeId);
193
+ // the root is always included even when the category was reached by a
194
+ // path that does not pass through it
195
+ const rootKey = WellKnownCategories.Aliases.toString();
196
+ if (!seen.has(rootKey) && this.addressSpace.findNode(WellKnownCategories.Aliases)) {
197
+ result.push(WellKnownCategories.Aliases);
198
+ }
199
+ return result;
200
+ }
201
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @module node-opcua-alias-name-server
3
+ *
4
+ * The fixed NodeIds of OPC 10000-17.
5
+ *
6
+ * Every well-known instance is resolved **by NodeId**, never by walking
7
+ * BrowseNames. Clause 9.1 gives these Objects static NodeIds precisely so they
8
+ * can be found without browsing, and a BrowseName walk would break on a Server
9
+ * that publishes the hierarchy under a localised DisplayName or that has a
10
+ * vendor Object of the same BrowseName elsewhere.
11
+ */
12
+
13
+ import { DataTypeIds, MethodIds, ObjectIds, ObjectTypeIds, ReferenceTypeIds, VariableIds } from "node-opcua-constants";
14
+ import { type NodeId, resolveNodeId } from "node-opcua-nodeid";
15
+
16
+ /**
17
+ * Default result cap; beyond it a call answers `Bad_ResponseTooLarge`
18
+ * (clause 6.3.2 Table 4).
19
+ *
20
+ * Lives here rather than in `install_alias_names` so that `bind_alias_category`
21
+ * can use it without the two importing each other.
22
+ */
23
+ export const DEFAULT_MAX_RESULTS = 1000;
24
+
25
+ /** `AliasNameType` ObjectType (clause 6.2). */
26
+ export const ALIAS_NAME_TYPE: NodeId = resolveNodeId(ObjectTypeIds.AliasNameType);
27
+
28
+ /** `AliasNameCategoryType` ObjectType (clause 6.3). */
29
+ export const ALIAS_NAME_CATEGORY_TYPE: NodeId = resolveNodeId(ObjectTypeIds.AliasNameCategoryType);
30
+
31
+ /** `VersionTime` DataType (OPC 10000-4 clause 7.43) - a UInt32 subtype. */
32
+ export const VERSION_TIME_DATA_TYPE: NodeId = resolveNodeId(DataTypeIds.VersionTime);
33
+
34
+ /** `AliasFor` ReferenceType (clause 8.2); the default link to a target Node. */
35
+ export const ALIAS_FOR: NodeId = resolveNodeId(ReferenceTypeIds.AliasFor);
36
+
37
+ /** `PublishedDataSetType` (OPC 10000-14), the target type `Topics` restricts to. */
38
+ export const PUBLISHED_DATA_SET_TYPE: NodeId = resolveNodeId(ObjectTypeIds.PublishedDataSetType);
39
+
40
+ /** The three well-known category instances of clauses 9.2, 9.3 and 9.4. */
41
+ export const WellKnownCategories = {
42
+ /** `Aliases`, the root of the hierarchy (clause 9.2). */
43
+ Aliases: resolveNodeId(ObjectIds.Aliases),
44
+ /** `TagVariables`; targets restricted to Variables (clause 9.3). */
45
+ TagVariables: resolveNodeId(ObjectIds.TagVariables),
46
+ /** `Topics`; targets restricted to PublishedDataSetType (clause 9.4). */
47
+ Topics: resolveNodeId(ObjectIds.Topics)
48
+ } as const;
49
+
50
+ /** `LastChange` on each well-known category (clause 6.3.1). */
51
+ export const WellKnownLastChange = {
52
+ Aliases: resolveNodeId(VariableIds.Aliases_LastChange),
53
+ TagVariables: resolveNodeId(VariableIds.TagVariables_LastChange),
54
+ Topics: resolveNodeId(VariableIds.Topics_LastChange)
55
+ } as const;
56
+
57
+ /** The Method declarations on `AliasNameCategoryType`. */
58
+ export const MethodDeclarations = {
59
+ FindAlias: resolveNodeId(MethodIds.AliasNameCategoryType_FindAlias),
60
+ FindAliasVerbose: resolveNodeId(MethodIds.AliasNameCategoryType_FindAliasVerbose),
61
+ AddAliasesToCategory: resolveNodeId(MethodIds.AliasNameCategoryType_AddAliasesToCategory),
62
+ DeleteAliasesFromCategory: resolveNodeId(MethodIds.AliasNameCategoryType_DeleteAliasesFromCategory)
63
+ } as const;
64
+
65
+ /**
66
+ * The NodeIds OPC 10000-17 reserves for the *optional* Methods on the three
67
+ * well-known categories.
68
+ *
69
+ * The shipped `Opc.Ua.NodeSet2.xml` declares these Methods on
70
+ * `AliasNameCategoryType` but does not instantiate them on `Aliases`,
71
+ * `TagVariables` or `Topics` — yet upstream still assigns them fixed NodeIds.
72
+ * When installation adds them it uses these rather than server-assigned ones,
73
+ * so an aggregating Server sees the standard NodeId it expects.
74
+ */
75
+ export const WellKnownOptionalMethods = {
76
+ Aliases: {
77
+ FindAliasVerbose: resolveNodeId(MethodIds.Aliases_FindAliasVerbose),
78
+ AddAliasesToCategory: resolveNodeId(MethodIds.Aliases_AddAliasesToCategory),
79
+ DeleteAliasesFromCategory: resolveNodeId(MethodIds.Aliases_DeleteAliasesFromCategory)
80
+ },
81
+ TagVariables: {
82
+ FindAliasVerbose: resolveNodeId(MethodIds.TagVariables_FindAliasVerbose),
83
+ AddAliasesToCategory: resolveNodeId(MethodIds.TagVariables_AddAliasesToCategory),
84
+ DeleteAliasesFromCategory: resolveNodeId(MethodIds.TagVariables_DeleteAliasesFromCategory)
85
+ },
86
+ Topics: {
87
+ FindAliasVerbose: resolveNodeId(MethodIds.Topics_FindAliasVerbose),
88
+ AddAliasesToCategory: resolveNodeId(MethodIds.Topics_AddAliasesToCategory),
89
+ DeleteAliasesFromCategory: resolveNodeId(MethodIds.Topics_DeleteAliasesFromCategory)
90
+ }
91
+ } as const;
92
+
93
+ /**
94
+ * The `ALIAS` ServerCapability identifier (OPC 10000-12 Annex D Table D.1).
95
+ *
96
+ * A Server that does not advertise this will never be found by anything looking
97
+ * for alias-capable Servers, and nothing reports that failure. Part 17's prose
98
+ * writes it `Alias`; Part 12 Annex D is the normative source and writes `ALIAS`,
99
+ * matched case-insensitively.
100
+ */
101
+ export const ALIAS_SERVER_CAPABILITY = "ALIAS";