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.
- package/LICENSE +22 -0
- package/README.md +361 -0
- package/dist/add_alias.d.ts +73 -0
- package/dist/add_alias.js +262 -0
- package/dist/add_alias.js.map +1 -0
- package/dist/address_space_alias_store.d.ts +130 -0
- package/dist/address_space_alias_store.js +360 -0
- package/dist/address_space_alias_store.js.map +1 -0
- package/dist/alias_hierarchy.d.ts +45 -0
- package/dist/alias_hierarchy.js +135 -0
- package/dist/alias_hierarchy.js.map +1 -0
- package/dist/alias_index.d.ts +60 -0
- package/dist/alias_index.js +119 -0
- package/dist/alias_index.js.map +1 -0
- package/dist/alias_name_archive.d.ts +45 -0
- package/dist/alias_name_archive.js +75 -0
- package/dist/alias_name_archive.js.map +1 -0
- package/dist/bind_alias_category.d.ts +173 -0
- package/dist/bind_alias_category.js +417 -0
- package/dist/bind_alias_category.js.map +1 -0
- package/dist/bind_configuration_methods.d.ts +44 -0
- package/dist/bind_configuration_methods.js +175 -0
- package/dist/bind_configuration_methods.js.map +1 -0
- package/dist/bind_find_alias.d.ts +61 -0
- package/dist/bind_find_alias.js +227 -0
- package/dist/bind_find_alias.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +64 -0
- package/dist/index.js.map +1 -0
- package/dist/install_alias_names.d.ts +229 -0
- package/dist/install_alias_names.js +146 -0
- package/dist/install_alias_names.js.map +1 -0
- package/dist/last_change.d.ts +87 -0
- package/dist/last_change.js +174 -0
- package/dist/last_change.js.map +1 -0
- package/dist/well_known.d.ts +88 -0
- package/dist/well_known.js +93 -0
- package/dist/well_known.js.map +1 -0
- package/package.json +54 -0
- package/source/add_alias.ts +318 -0
- package/source/address_space_alias_store.ts +417 -0
- package/source/alias_hierarchy.ts +141 -0
- package/source/alias_index.ts +127 -0
- package/source/alias_name_archive.ts +84 -0
- package/source/bind_alias_category.ts +546 -0
- package/source/bind_configuration_methods.ts +219 -0
- package/source/bind_find_alias.ts +290 -0
- package/source/index.ts +74 -0
- package/source/install_alias_names.ts +342 -0
- package/source/last_change.ts +201 -0
- package/source/well_known.ts +101 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module node-opcua-alias-name-server
|
|
3
|
+
*
|
|
4
|
+
* Persistence for `LastChange` (OPC 10000-17 clause 6.3.1).
|
|
5
|
+
*
|
|
6
|
+
* Clause 6.3.1 is blunt about why this exists: *"The LastChange shall be
|
|
7
|
+
* persisted. A Client that detects a LastChange that is older than what it has
|
|
8
|
+
* cached, shall clear all cached AliasNameCategories and related AliasNames."*
|
|
9
|
+
*
|
|
10
|
+
* So a restart that reset `LastChange` to zero would not merely lose
|
|
11
|
+
* information — it would order every connected Client to throw away a cache
|
|
12
|
+
* that is still perfectly valid, silently and on every restart. That is a
|
|
13
|
+
* Server-side bug whose only symptom is remote.
|
|
14
|
+
*
|
|
15
|
+
* The archive is plain JSON: a version and a map of category NodeId to
|
|
16
|
+
* VersionTime. There is nothing secret in it, so unlike the RoleSet archive it
|
|
17
|
+
* is not encrypted — it is a handful of integers describing when things last
|
|
18
|
+
* changed. Writes are atomic (temp file + rename) so a crash cannot leave a
|
|
19
|
+
* half-written archive, which would be worse than no archive at all.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { promises as fs } from "node:fs";
|
|
23
|
+
import { dirname } from "node:path";
|
|
24
|
+
|
|
25
|
+
/** Bumped when the on-disk shape changes incompatibly. */
|
|
26
|
+
export const ALIAS_NAME_ARCHIVE_VERSION = 1;
|
|
27
|
+
|
|
28
|
+
/** The persisted form of a Server's `LastChange` state. */
|
|
29
|
+
export interface AliasNameArchive {
|
|
30
|
+
version: number;
|
|
31
|
+
/** Category NodeId (as a string) to VersionTime (UInt32 seconds since 2000-01-01Z). */
|
|
32
|
+
lastChange: Record<string, number>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read an archive, or return `null` when there is none.
|
|
37
|
+
*
|
|
38
|
+
* A missing file is normal — the first start. A corrupt or
|
|
39
|
+
* future-versioned file is **not** silently ignored: continuing with a zeroed
|
|
40
|
+
* `LastChange` is exactly the cache-clearing bug persistence exists to prevent,
|
|
41
|
+
* so the caller is told rather than left to discover it from a Client.
|
|
42
|
+
*/
|
|
43
|
+
export async function readAliasNameArchive(path: string): Promise<AliasNameArchive | null> {
|
|
44
|
+
let raw: string;
|
|
45
|
+
try {
|
|
46
|
+
raw = await fs.readFile(path, "utf-8");
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let parsed: unknown;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(raw);
|
|
57
|
+
} catch {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`readAliasNameArchive: ${path} is not valid JSON. Delete it to start fresh, but note that a Client that has cached AliasNames will be told to clear its cache.`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const archive = parsed as Partial<AliasNameArchive>;
|
|
64
|
+
if (archive.version !== ALIAS_NAME_ARCHIVE_VERSION) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`readAliasNameArchive: ${path} has version ${String(archive.version)}, expected ${ALIAS_NAME_ARCHIVE_VERSION}`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return { version: archive.version, lastChange: archive.lastChange ?? {} };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Write an archive atomically.
|
|
74
|
+
*
|
|
75
|
+
* Temp file plus rename, so a crash mid-write leaves either the previous
|
|
76
|
+
* archive or the new one, never a truncated file that would fail to parse on
|
|
77
|
+
* the next start.
|
|
78
|
+
*/
|
|
79
|
+
export async function writeAliasNameArchive(path: string, archive: AliasNameArchive): Promise<void> {
|
|
80
|
+
await fs.mkdir(dirname(path), { recursive: true });
|
|
81
|
+
const temporaryPath = `${path}.tmp`;
|
|
82
|
+
await fs.writeFile(temporaryPath, JSON.stringify(archive, null, 2), "utf-8");
|
|
83
|
+
await fs.rename(temporaryPath, path);
|
|
84
|
+
}
|
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module node-opcua-alias-name-server
|
|
3
|
+
*
|
|
4
|
+
* Binding the Methods of a single `AliasNameCategoryType` instance, and creating
|
|
5
|
+
* new categories at runtime.
|
|
6
|
+
*
|
|
7
|
+
* This is the one binding path. `installAliasNamesOnAddressSpace` calls
|
|
8
|
+
* {@link bindAliasCategory} in its loop rather than doing the work itself, so a
|
|
9
|
+
* category created after installation cannot end up bound differently from one
|
|
10
|
+
* that was there at install time — or, worse, not bound at all. An unbound
|
|
11
|
+
* MANDATORY `FindAlias` is the exact defect this package exists to remove, and
|
|
12
|
+
* it should not be able to reappear at runtime.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
BaseNode,
|
|
17
|
+
IAddressSpace,
|
|
18
|
+
ISessionContext,
|
|
19
|
+
UAMethod,
|
|
20
|
+
UAObject,
|
|
21
|
+
UAObjectType,
|
|
22
|
+
UAVariable
|
|
23
|
+
} from "node-opcua-address-space-base";
|
|
24
|
+
import type { IAliasStore } from "node-opcua-alias-name-common";
|
|
25
|
+
import { BrowseDirection, NodeClass } from "node-opcua-data-model";
|
|
26
|
+
import { type NodeId, NodeId as NodeIdClass, NodeIdType } from "node-opcua-nodeid";
|
|
27
|
+
import type { RolePermissionTypeOptions } from "node-opcua-types";
|
|
28
|
+
import { DataType } from "node-opcua-variant";
|
|
29
|
+
import { makeAddAliasesToCategoryHandler, makeDeleteAliasesFromCategoryHandler } from "./bind_configuration_methods.js";
|
|
30
|
+
import { type AliasComparator, makeFindAliasHandler } from "./bind_find_alias.js";
|
|
31
|
+
import { LAST_CHANGE_BROWSE_NAME, type LastChangeTracker } from "./last_change.js";
|
|
32
|
+
import {
|
|
33
|
+
ALIAS_NAME_CATEGORY_TYPE,
|
|
34
|
+
DEFAULT_MAX_RESULTS,
|
|
35
|
+
MethodDeclarations,
|
|
36
|
+
VERSION_TIME_DATA_TYPE,
|
|
37
|
+
WellKnownCategories,
|
|
38
|
+
WellKnownOptionalMethods
|
|
39
|
+
} from "./well_known.js";
|
|
40
|
+
|
|
41
|
+
/** Everything a category needs in order to answer `FindAlias`. */
|
|
42
|
+
export interface BindAliasCategoryOptions {
|
|
43
|
+
/** Where aliases come from. */
|
|
44
|
+
store: IAliasStore;
|
|
45
|
+
/** Result cap per call (clause 6.3.2 Table 4). */
|
|
46
|
+
maxResults: number;
|
|
47
|
+
/** Also bind `FindAliasVerbose`, adding the Method if the instance lacks it. */
|
|
48
|
+
verbose?: boolean;
|
|
49
|
+
/** Result ordering (clause 6.3.2, "best match first"). */
|
|
50
|
+
comparator?: AliasComparator;
|
|
51
|
+
/** Read gate; see {@link FindAliasBindingOptions.isReadAllowed}. */
|
|
52
|
+
isReadAllowed?: (context: ISessionContext, categoryNodeId: NodeId) => boolean | Promise<boolean>;
|
|
53
|
+
/**
|
|
54
|
+
* Write gate for the configuration Methods, mirroring `isReadAllowed`.
|
|
55
|
+
* Defaults to denying everyone.
|
|
56
|
+
*/
|
|
57
|
+
isWriteAllowed?: (context: ISessionContext, categoryNodeId: NodeId) => boolean | Promise<boolean>;
|
|
58
|
+
/** Also add and bind `AddAliasesToCategory` / `DeleteAliasesFromCategory`. */
|
|
59
|
+
configurationMethods?: boolean;
|
|
60
|
+
/** Ensure the category carries a `LastChange` Property (clause 6.3.1). */
|
|
61
|
+
lastChangeProperty?: boolean;
|
|
62
|
+
/** Called after a configuration Method changed the category. */
|
|
63
|
+
onChanged?: (categoryNodeId: NodeId) => void | Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Bind `FindAlias` — and, when `verbose`, `FindAliasVerbose` — on one
|
|
68
|
+
* `AliasNameCategoryType` instance.
|
|
69
|
+
*
|
|
70
|
+
* Safe to call on a category that is already bound: `bindMethod` replaces the
|
|
71
|
+
* handler, and the optional Method is only added when it is missing.
|
|
72
|
+
*
|
|
73
|
+
* Use this for a category created after `installAliasNames` has run. The options
|
|
74
|
+
* that installation used are on {@link InstallAliasNamesResult.bindingOptions},
|
|
75
|
+
* so a caller does not have to reassemble them and risk binding a late category
|
|
76
|
+
* with a different store or a different result cap.
|
|
77
|
+
*/
|
|
78
|
+
export function bindAliasCategory(addressSpace: IAddressSpace, category: UAObject, options: BindAliasCategoryOptions): void {
|
|
79
|
+
const findAlias = findMethodByDeclaration(category, MethodDeclarations.FindAlias, "FindAlias");
|
|
80
|
+
findAlias?.bindMethod(makeFindAliasHandler(options, false));
|
|
81
|
+
|
|
82
|
+
if (options.verbose ?? true) {
|
|
83
|
+
const verbose = ensureOptionalMethod(addressSpace, category, "FindAliasVerbose");
|
|
84
|
+
verbose?.bindMethod(makeFindAliasHandler(options, true));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (options.lastChangeProperty ?? true) {
|
|
88
|
+
ensureLastChangeProperty(addressSpace, category);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The write surface only appears when asked for (clause 6.3.4 / 6.3.5 are
|
|
92
|
+
// both Optional), and even then every call is denied unless isWriteAllowed
|
|
93
|
+
// says otherwise.
|
|
94
|
+
if (options.configurationMethods) {
|
|
95
|
+
const configurationOptions = {
|
|
96
|
+
store: options.store,
|
|
97
|
+
isWriteAllowed: options.isWriteAllowed,
|
|
98
|
+
onChanged: options.onChanged
|
|
99
|
+
};
|
|
100
|
+
const add = ensureOptionalMethod(addressSpace, category, "AddAliasesToCategory");
|
|
101
|
+
add?.bindMethod(makeAddAliasesToCategoryHandler(configurationOptions));
|
|
102
|
+
const remove = ensureOptionalMethod(addressSpace, category, "DeleteAliasesFromCategory");
|
|
103
|
+
remove?.bindMethod(makeDeleteAliasesFromCategoryHandler(configurationOptions));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The default NodeId for a new category: a **string** NodeId spelling out its
|
|
109
|
+
* path under `Aliases`, for instance `ns=1;s=Aliases/TagVariables/Unit200`.
|
|
110
|
+
*
|
|
111
|
+
* The obvious alternative — letting the namespace assign the next free numeric
|
|
112
|
+
* id — is wrong here for two reasons.
|
|
113
|
+
*
|
|
114
|
+
* It is **not stable across restarts**. The counter depends on how many other
|
|
115
|
+
* Nodes were created first, so adding one unrelated Variable to a Server shifts
|
|
116
|
+
* every category's NodeId. That silently breaks `LastChange` persistence, which
|
|
117
|
+
* keys on the category NodeId: the restored values no longer match any
|
|
118
|
+
* category, `LastChange` reads 0, and clause 6.3.1 then requires every connected
|
|
119
|
+
* Client to clear a cache that was perfectly valid. A Server-side change with a
|
|
120
|
+
* purely remote symptom.
|
|
121
|
+
*
|
|
122
|
+
* It is also **not diagnosable**. `ns=1;i=1010` in a log or a
|
|
123
|
+
* `FindAliasVerbose` result says nothing; `ns=1;s=Aliases/TagVariables/Unit200`
|
|
124
|
+
* says which category it is without a lookup.
|
|
125
|
+
*
|
|
126
|
+
* The path is unique because two categories cannot share a parent and a
|
|
127
|
+
* BrowseName, and it is derived from the same information every run, so it is
|
|
128
|
+
* the same NodeId every run.
|
|
129
|
+
*/
|
|
130
|
+
function defaultCategoryNodeId(parent: UAObject, browseName: string, namespaceIndex: number): NodeId {
|
|
131
|
+
const path = [...categoryPathOf(parent), browseName].join("/");
|
|
132
|
+
return new NodeIdClass(NodeIdType.STRING, path, namespaceIndex);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The BrowseNames from the `Aliases` root down to `category`, inclusive.
|
|
137
|
+
*
|
|
138
|
+
* Falls back to the category's own BrowseName when it is not under the root,
|
|
139
|
+
* which keeps the id derivable for a category modelled outside the standard
|
|
140
|
+
* hierarchy.
|
|
141
|
+
*/
|
|
142
|
+
function categoryPathOf(category: UAObject): string[] {
|
|
143
|
+
const segments: string[] = [];
|
|
144
|
+
const seen = new Set<string>();
|
|
145
|
+
let current: UAObject | null = category;
|
|
146
|
+
|
|
147
|
+
while (current) {
|
|
148
|
+
const key: string = current.nodeId.toString();
|
|
149
|
+
if (seen.has(key)) {
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
seen.add(key);
|
|
153
|
+
segments.unshift(current.browseName.name ?? key);
|
|
154
|
+
|
|
155
|
+
if (key === WellKnownCategories.Aliases.toString()) {
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
const parents: BaseNode[] = current.findReferencesExAsObject("HierarchicalReferences", BrowseDirection.Inverse);
|
|
159
|
+
const next = parents.find((p) => p.nodeClass === NodeClass.Object);
|
|
160
|
+
current = next ? (next as UAObject) : null;
|
|
161
|
+
}
|
|
162
|
+
return segments;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Ensure a category has a `LastChange` Property.
|
|
167
|
+
*
|
|
168
|
+
* `LastChange` is Optional on `AliasNameCategoryType` and the shipped nodeset
|
|
169
|
+
* instantiates it only on the `Aliases` root, which clause 9.2 makes mandatory.
|
|
170
|
+
* Adding it to every category is conformant — Optional means may, not must not —
|
|
171
|
+
* and it is what makes the clause 6.3.1 rollup observable: without it, a Client
|
|
172
|
+
* watching one branch has nothing to watch.
|
|
173
|
+
*/
|
|
174
|
+
export function ensureLastChangeProperty(addressSpace: IAddressSpace, category: UAObject): UAVariable | null {
|
|
175
|
+
const existing = category.getPropertyByName(LAST_CHANGE_BROWSE_NAME);
|
|
176
|
+
if (existing) {
|
|
177
|
+
return existing;
|
|
178
|
+
}
|
|
179
|
+
const namespace = addressSpace.getNamespace(
|
|
180
|
+
category.nodeId.namespace === 0 ? addressSpace.getOwnNamespace().index : category.nodeId.namespace
|
|
181
|
+
);
|
|
182
|
+
return namespace.addVariable({
|
|
183
|
+
propertyOf: category,
|
|
184
|
+
browseName: LAST_CHANGE_BROWSE_NAME,
|
|
185
|
+
// VersionTime (i=20998) is a UInt32 subtype, not a DateTime
|
|
186
|
+
dataType: VERSION_TIME_DATA_TYPE,
|
|
187
|
+
minimumSamplingInterval: 1000,
|
|
188
|
+
value: { dataType: DataType.UInt32, value: 0 }
|
|
189
|
+
}) as UAVariable;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface AddAliasCategoryOptions extends Partial<BindAliasCategoryOptions> {
|
|
193
|
+
/**
|
|
194
|
+
* Namespace for the new category's BrowseName. Defaults to the Server's own.
|
|
195
|
+
*/
|
|
196
|
+
namespaceIndex?: number;
|
|
197
|
+
/**
|
|
198
|
+
* NodeId for the new category. Defaults to a server-assigned one, which is
|
|
199
|
+
* correct for any category the specification does not name.
|
|
200
|
+
*/
|
|
201
|
+
nodeId?: NodeId;
|
|
202
|
+
/**
|
|
203
|
+
* ObjectType to instantiate. Defaults to `AliasNameCategoryType`; a subtype
|
|
204
|
+
* is accepted, since discovery and binding both already handle subtypes.
|
|
205
|
+
*/
|
|
206
|
+
categoryType?: UAObjectType | NodeId;
|
|
207
|
+
/**
|
|
208
|
+
* RolePermissions for the new category.
|
|
209
|
+
*
|
|
210
|
+
* Worth setting deliberately. Namespace 0 declares no `RolePermissions` on
|
|
211
|
+
* any Part 17 node, so a category created without them inherits the
|
|
212
|
+
* namespace default silently — which is a decision either way, just an
|
|
213
|
+
* invisible one.
|
|
214
|
+
*/
|
|
215
|
+
rolePermissions?: RolePermissionTypeOptions[];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Create a vendor `AliasNameCategoryType` instance under `parent` **and bind it**.
|
|
220
|
+
*
|
|
221
|
+
* Creating one by hand means instantiating the type, wiring the `Organizes`
|
|
222
|
+
* reference and then remembering to bind — and a category whose `FindAlias` is
|
|
223
|
+
* unbound fails conformance silently. This does all three.
|
|
224
|
+
*
|
|
225
|
+
* When `installAliasNames` has already run on this address space, the binding
|
|
226
|
+
* options it used are reused unless overridden, so a category added at runtime
|
|
227
|
+
* behaves exactly like one that was present at install time. Pass a `store`
|
|
228
|
+
* explicitly if installation has not run yet.
|
|
229
|
+
*/
|
|
230
|
+
export function addAliasCategory(
|
|
231
|
+
addressSpace: IAddressSpace,
|
|
232
|
+
parent: UAObject | NodeId,
|
|
233
|
+
browseName: string,
|
|
234
|
+
options?: AddAliasCategoryOptions
|
|
235
|
+
): UAObject {
|
|
236
|
+
const parentNode = coerceCategoryNode(addressSpace, parent);
|
|
237
|
+
const categoryType = resolveCategoryType(addressSpace, options?.categoryType);
|
|
238
|
+
|
|
239
|
+
const namespace = addressSpace.getNamespace(options?.namespaceIndex ?? addressSpace.getOwnNamespace().index);
|
|
240
|
+
const nodeId = options?.nodeId ?? defaultCategoryNodeId(parentNode, browseName, namespace.index);
|
|
241
|
+
|
|
242
|
+
if (addressSpace.findNode(nodeId)) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`addAliasCategory: ${nodeId.toString()} already exists. A category's default NodeId is derived from its ` +
|
|
245
|
+
"path under Aliases, so this means a category of the same name already exists under the same parent."
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const category = categoryType.instantiate({
|
|
250
|
+
browseName: { name: browseName, namespaceIndex: namespace.index },
|
|
251
|
+
nodeId,
|
|
252
|
+
organizedBy: parentNode,
|
|
253
|
+
namespace
|
|
254
|
+
}) as UAObject;
|
|
255
|
+
|
|
256
|
+
if (options?.rolePermissions) {
|
|
257
|
+
// instantiate() does not take them, so they are applied after
|
|
258
|
+
category.setRolePermissions(options.rolePermissions);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const bindingOptions = resolveBindingOptions(addressSpace, options);
|
|
262
|
+
if (bindingOptions) {
|
|
263
|
+
bindAliasCategory(addressSpace, category, bindingOptions);
|
|
264
|
+
}
|
|
265
|
+
// clause 6.3.1: "The last time an AliasNameCategory was added or deleted"
|
|
266
|
+
notifyCategoryChanged(addressSpace, parentNode.nodeId);
|
|
267
|
+
return category;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Resolve the ObjectType to instantiate, defaulting to `AliasNameCategoryType`.
|
|
272
|
+
*
|
|
273
|
+
* A subtype is accepted: discovery matches on "is this an instance of
|
|
274
|
+
* AliasNameCategoryType *or a subtype*", and binding looks the Methods up by
|
|
275
|
+
* MethodDeclarationId, so neither cares which exact type was used.
|
|
276
|
+
*/
|
|
277
|
+
function resolveCategoryType(addressSpace: IAddressSpace, categoryType?: UAObjectType | NodeId): UAObjectType {
|
|
278
|
+
const base = addressSpace.findObjectType(ALIAS_NAME_CATEGORY_TYPE);
|
|
279
|
+
if (!base) {
|
|
280
|
+
throw new Error("addAliasCategory: AliasNameCategoryType (i=23456) is not in the address space");
|
|
281
|
+
}
|
|
282
|
+
if (!categoryType) {
|
|
283
|
+
return base;
|
|
284
|
+
}
|
|
285
|
+
const resolved =
|
|
286
|
+
categoryType instanceof NodeIdClass ? addressSpace.findObjectType(categoryType) : (categoryType as UAObjectType);
|
|
287
|
+
if (!resolved) {
|
|
288
|
+
throw new Error(`addAliasCategory: unknown ObjectType ${String(categoryType)}`);
|
|
289
|
+
}
|
|
290
|
+
if (resolved.nodeId.value !== base.nodeId.value && !resolved.isSubtypeOf(base)) {
|
|
291
|
+
throw new Error(`addAliasCategory: ${resolved.browseName.toString()} is not AliasNameCategoryType or a subtype of it`);
|
|
292
|
+
}
|
|
293
|
+
return resolved;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Remove a category, and decide what happens to what it Organizes.
|
|
298
|
+
*
|
|
299
|
+
* The specification does not say, so the rule is stated here rather than left to
|
|
300
|
+
* whatever `deleteNode` happens to do:
|
|
301
|
+
*
|
|
302
|
+
* - **`reparent`** (the default) moves the category's aliases and subcategories
|
|
303
|
+
* to its parent before deleting it. Nothing disappears, so a Client that had
|
|
304
|
+
* resolved an alias keeps resolving it — the alias Object keeps its NodeId,
|
|
305
|
+
* and clause 6.2 makes a NodeId change mean "this is a different alias".
|
|
306
|
+
* - **`cascade`** deletes them with it. Correct when the category *is* the
|
|
307
|
+
* thing being retired, such as a tenant being removed.
|
|
308
|
+
*
|
|
309
|
+
* Refuses to remove one of the three well-known categories, which clause 9
|
|
310
|
+
* requires a Server to have.
|
|
311
|
+
*
|
|
312
|
+
* @returns the aliases and subcategories that were re-parented, or deleted.
|
|
313
|
+
*/
|
|
314
|
+
export function removeAliasCategory(
|
|
315
|
+
addressSpace: IAddressSpace,
|
|
316
|
+
category: UAObject | NodeId,
|
|
317
|
+
options?: { orphans?: "reparent" | "cascade" }
|
|
318
|
+
): { moved: NodeId[]; deleted: NodeId[] } {
|
|
319
|
+
const node = coerceCategoryNode(addressSpace, category);
|
|
320
|
+
for (const wellKnown of Object.values(WellKnownCategories)) {
|
|
321
|
+
if (wellKnown.value === node.nodeId.value && wellKnown.namespace === node.nodeId.namespace) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
`removeAliasCategory: ${node.browseName.toString()} is a well-known category that ` +
|
|
324
|
+
"OPC 10000-17 clause 9 requires the Server to have"
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const parents = node.findReferencesExAsObject("HierarchicalReferences", BrowseDirection.Inverse);
|
|
330
|
+
// Organizes only, not every hierarchical reference. A category's Methods are
|
|
331
|
+
// HasComponent children that belong to it and must go with it; re-parenting
|
|
332
|
+
// those would leave the parent with a second FindAlias.
|
|
333
|
+
const childReferences = node.findReferencesEx("Organizes", BrowseDirection.Forward);
|
|
334
|
+
const orphans = options?.orphans ?? "reparent";
|
|
335
|
+
const moved: NodeId[] = [];
|
|
336
|
+
const deleted: NodeId[] = [];
|
|
337
|
+
|
|
338
|
+
if (orphans === "reparent") {
|
|
339
|
+
const parent = parents[0];
|
|
340
|
+
if (!parent) {
|
|
341
|
+
throw new Error(
|
|
342
|
+
`removeAliasCategory: ${node.browseName.toString()} has no parent to re-parent its contents to; ` +
|
|
343
|
+
'pass { orphans: "cascade" } to delete them instead'
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
for (const reference of childReferences) {
|
|
347
|
+
parent.addReference({ referenceType: reference.referenceType, nodeId: reference.nodeId });
|
|
348
|
+
// Detach before deleting: deleteNode cascades through Organizes, so
|
|
349
|
+
// a child still referenced here would be deleted along with the
|
|
350
|
+
// category despite having just been re-parented.
|
|
351
|
+
node.removeReference({ referenceType: reference.referenceType, nodeId: reference.nodeId });
|
|
352
|
+
moved.push(reference.nodeId);
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
for (const reference of childReferences) {
|
|
356
|
+
deleted.push(reference.nodeId);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// whatever is still attached goes with it
|
|
361
|
+
addressSpace.deleteNode(node.nodeId);
|
|
362
|
+
// clause 6.3.1: a category was deleted
|
|
363
|
+
for (const parent of parents) {
|
|
364
|
+
notifyCategoryChanged(addressSpace, parent.nodeId);
|
|
365
|
+
}
|
|
366
|
+
return { moved, deleted };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Tell the installed `LastChange` tracker that a category's contents changed.
|
|
371
|
+
*
|
|
372
|
+
* Fire and forget: the Property is written synchronously and only the
|
|
373
|
+
* persistence write is async, so a failure to persist must not fail the
|
|
374
|
+
* caller's structural change.
|
|
375
|
+
*/
|
|
376
|
+
function notifyCategoryChanged(addressSpace: IAddressSpace, categoryNodeId: NodeId): void {
|
|
377
|
+
const installed = getInstalledAliasNames(addressSpace);
|
|
378
|
+
void installed?.lastChange?.touch(categoryNodeId);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Merge explicit options over whatever installation recorded, or return null
|
|
383
|
+
* when there is nothing to bind with.
|
|
384
|
+
*/
|
|
385
|
+
function resolveBindingOptions(addressSpace: IAddressSpace, options?: AddAliasCategoryOptions): BindAliasCategoryOptions | null {
|
|
386
|
+
const installed = getInstalledAliasNames(addressSpace);
|
|
387
|
+
const inherited = installed?.bindingOptions;
|
|
388
|
+
const store = options?.store ?? inherited?.store;
|
|
389
|
+
if (!store) {
|
|
390
|
+
// nothing to bind against yet; installAliasNames will pick this category
|
|
391
|
+
// up when it runs, since it is Organized below its parent
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Inherit by spreading rather than by listing fields. Cherry-picking meant
|
|
396
|
+
// every new binding option had to be remembered here too, and forgetting one
|
|
397
|
+
// let a category created at runtime diverge from an installed one - silently,
|
|
398
|
+
// and in whichever direction was least safe.
|
|
399
|
+
const merged: BindAliasCategoryOptions = {
|
|
400
|
+
...inherited,
|
|
401
|
+
store,
|
|
402
|
+
maxResults: options?.maxResults ?? inherited?.maxResults ?? DEFAULT_MAX_RESULTS
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// Only keys the caller actually supplied override the inherited value; an
|
|
406
|
+
// absent key must not read as "false".
|
|
407
|
+
for (const key of Object.keys(options ?? {}) as Array<keyof AddAliasCategoryOptions>) {
|
|
408
|
+
const value = options?.[key];
|
|
409
|
+
if (
|
|
410
|
+
value === undefined ||
|
|
411
|
+
key === "namespaceIndex" ||
|
|
412
|
+
key === "nodeId" ||
|
|
413
|
+
key === "categoryType" ||
|
|
414
|
+
key === "rolePermissions"
|
|
415
|
+
) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
Object.assign(merged, { [key]: value });
|
|
419
|
+
}
|
|
420
|
+
return merged;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Accept a category node or its NodeId. */
|
|
424
|
+
function coerceCategoryNode(addressSpace: IAddressSpace, node: UAObject | NodeId): UAObject {
|
|
425
|
+
if (!(node instanceof NodeIdClass)) {
|
|
426
|
+
return node;
|
|
427
|
+
}
|
|
428
|
+
const found = addressSpace.findNode(node);
|
|
429
|
+
if (!found || found.nodeClass !== NodeClass.Object) {
|
|
430
|
+
throw new Error(`addAliasCategory: ${node.toString()} is not an Object in this address space`);
|
|
431
|
+
}
|
|
432
|
+
return found as UAObject;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Find a Method on a category by its MethodDeclarationId, falling back to the
|
|
437
|
+
* BrowseName.
|
|
438
|
+
*
|
|
439
|
+
* The declaration id is the reliable key: a Server may publish the Method under
|
|
440
|
+
* a localised DisplayName, and the BrowseName is only unique within the
|
|
441
|
+
* namespace. The fallback covers instances built in code, which do not always
|
|
442
|
+
* carry a `methodDeclarationId`.
|
|
443
|
+
*/
|
|
444
|
+
export function findMethodByDeclaration(category: UAObject, declarationId: NodeId, browseName: string): UAMethod | null {
|
|
445
|
+
for (const component of category.getComponents()) {
|
|
446
|
+
if (component.nodeClass !== NodeClass.Method) {
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const method = component as UAMethod;
|
|
450
|
+
if (method.methodDeclarationId && method.methodDeclarationId.value === declarationId.value) {
|
|
451
|
+
return method;
|
|
452
|
+
}
|
|
453
|
+
if (method.browseName.name === browseName) {
|
|
454
|
+
return method;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Ensure an optional Method exists on a category, adding it when the nodeset
|
|
462
|
+
* only declared it on the type.
|
|
463
|
+
*
|
|
464
|
+
* The shipped `Opc.Ua.NodeSet2.xml` declares `FindAliasVerbose`,
|
|
465
|
+
* `AddAliasesToCategory` and `DeleteAliasesFromCategory` on
|
|
466
|
+
* `AliasNameCategoryType` but instantiates none of them on `Aliases`,
|
|
467
|
+
* `TagVariables` or `Topics`. Upstream nonetheless reserves fixed NodeIds for
|
|
468
|
+
* those instances, so where one exists it is used in preference to a
|
|
469
|
+
* server-assigned id; an aggregating Server then sees the NodeId it expects.
|
|
470
|
+
*/
|
|
471
|
+
export function ensureOptionalMethod(
|
|
472
|
+
addressSpace: IAddressSpace,
|
|
473
|
+
category: UAObject,
|
|
474
|
+
name: "FindAliasVerbose" | "AddAliasesToCategory" | "DeleteAliasesFromCategory"
|
|
475
|
+
): UAMethod | null {
|
|
476
|
+
const declarationId = MethodDeclarations[name];
|
|
477
|
+
const existing = findMethodByDeclaration(category, declarationId, name);
|
|
478
|
+
if (existing) {
|
|
479
|
+
return existing;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const declaration = addressSpace.findNode(declarationId);
|
|
483
|
+
if (!declaration || declaration.nodeClass !== NodeClass.Method) {
|
|
484
|
+
// an address space whose nodeset predates the optional Methods
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const reservedNodeId = reservedMethodNodeId(category.nodeId, name);
|
|
489
|
+
// a reserved id already taken by something else means the address space is
|
|
490
|
+
// not what we think it is; fall back to a server-assigned id rather than
|
|
491
|
+
// colliding
|
|
492
|
+
const nodeId = reservedNodeId && !addressSpace.findNode(reservedNodeId) ? reservedNodeId : undefined;
|
|
493
|
+
|
|
494
|
+
return (declaration as UAMethod).clone({
|
|
495
|
+
namespace: addressSpace.getNamespace(category.nodeId.namespace),
|
|
496
|
+
nodeId,
|
|
497
|
+
componentOf: category,
|
|
498
|
+
methodDeclarationId: declarationId
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** The NodeId OPC 10000-17 reserves for an optional Method on a well-known category. */
|
|
503
|
+
function reservedMethodNodeId(
|
|
504
|
+
categoryNodeId: NodeId,
|
|
505
|
+
name: "FindAliasVerbose" | "AddAliasesToCategory" | "DeleteAliasesFromCategory"
|
|
506
|
+
): NodeId | undefined {
|
|
507
|
+
for (const [key, wellKnownId] of Object.entries(WellKnownCategories)) {
|
|
508
|
+
if (wellKnownId.value === categoryNodeId.value && wellKnownId.namespace === categoryNodeId.namespace) {
|
|
509
|
+
return WellKnownOptionalMethods[key as keyof typeof WellKnownOptionalMethods][name];
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Marks an address space as already carrying AliasName bindings, so a second
|
|
517
|
+
* `installAliasNames` is a no-op rather than a double binding.
|
|
518
|
+
*/
|
|
519
|
+
export const INSTALLED = Symbol.for("node-opcua-alias-name-server.installed");
|
|
520
|
+
|
|
521
|
+
/** What installation recorded on the address space, if it has run. */
|
|
522
|
+
export interface InstalledAliasNames {
|
|
523
|
+
store: IAliasStore;
|
|
524
|
+
categories: NodeId[];
|
|
525
|
+
bindingOptions: BindAliasCategoryOptions;
|
|
526
|
+
/** Keeps `LastChange` correct across the hierarchy (clause 6.3.1). */
|
|
527
|
+
lastChange?: LastChangeTracker;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
type MaybeInstalled = { [INSTALLED]?: InstalledAliasNames };
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* What `installAliasNames` recorded on this address space, or undefined if it
|
|
534
|
+
* has not run.
|
|
535
|
+
*
|
|
536
|
+
* Exposed so a caller can rebind a late category with exactly the options
|
|
537
|
+
* installation used, without having to keep the install result around.
|
|
538
|
+
*/
|
|
539
|
+
export function getInstalledAliasNames(addressSpace: IAddressSpace): InstalledAliasNames | undefined {
|
|
540
|
+
return (addressSpace as IAddressSpace & MaybeInstalled)[INSTALLED];
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Record the installation on the address space. */
|
|
544
|
+
export function setInstalledAliasNames(addressSpace: IAddressSpace, value: InstalledAliasNames): void {
|
|
545
|
+
(addressSpace as IAddressSpace & MaybeInstalled)[INSTALLED] = value;
|
|
546
|
+
}
|