opcjs-client 0.1.13 → 0.1.14
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/README.md +303 -0
- package/dist/index.cjs +1387 -123
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +787 -12
- package/dist/index.d.ts +787 -12
- package/dist/index.js +1381 -125
- package/dist/index.js.map +1 -1
- package/package.json +10 -6
package/dist/index.js
CHANGED
|
@@ -1,23 +1,87 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { NodeId, StatusCodeToString, initLoggerProvider, getLogger, ServerStateEnum, WebSocketFascade, WebSocketReadableStream, WebSocketWritableStream, SecureChannelContext, TcpMessageInjector, TcpConnectionHandler, TcpMessageDecoupler, SecureChannelMessageEncoder, SecureChannelTypeDecoder, SecureChannelMessageDecoder, SecureChannelTypeEncoder, SecureChannelChunkWriter, SecureChannelChunkReader, SecureChannelFacade, CallMethodRequest, Variant, BrowseDescription, BrowseDirectionEnum, BrowseResultMaskEnum, Configuration, LoggerFactory, Encoder, BinaryWriter, registerEncoders, Decoder, BinaryReader, registerTypeDecoders, registerBinaryDecoders, UserTokenTypeEnum, AnonymousIdentityToken, UserNameIdentityToken, IssuedIdentityToken, TimestampsToReturnEnum, ReadValueId, QualifiedName, ReadRequest, StatusCode, CallRequest, ViewDescription, BrowseRequest, BrowseNextRequest, SubscriptionAcknowledgement, ExpandedNodeId, CreateSubscriptionRequest, PublishRequest, MonitoringParameters, ExtensionObject, MonitoredItemCreateRequest, MonitoringModeEnum, CreateMonitoredItemsRequest, StatusCodeToStringNumber, RequestHeader, ApplicationDescription, LocalizedText, ApplicationTypeEnum, CreateSessionRequest, CreateSessionResponse, SignatureData, ActivateSessionRequest, CancelRequest, CloseSessionRequest } from 'opcjs-base';
|
|
2
2
|
|
|
3
3
|
// src/client.ts
|
|
4
|
+
var SessionInvalidError = class extends Error {
|
|
5
|
+
statusCode;
|
|
6
|
+
constructor(statusCode) {
|
|
7
|
+
super(`Session is no longer valid: ${StatusCodeToString(statusCode)} (0x${statusCode.toString(16).toUpperCase()})`);
|
|
8
|
+
this.name = "SessionInvalidError";
|
|
9
|
+
this.statusCode = statusCode;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// src/services/serviceBase.ts
|
|
14
|
+
var requestHandleCounter = 0;
|
|
15
|
+
function nextRequestHandle() {
|
|
16
|
+
if (requestHandleCounter >= 2147483647) {
|
|
17
|
+
requestHandleCounter = 1;
|
|
18
|
+
} else {
|
|
19
|
+
requestHandleCounter++;
|
|
20
|
+
}
|
|
21
|
+
return requestHandleCounter;
|
|
22
|
+
}
|
|
23
|
+
function lastAssignedHandle() {
|
|
24
|
+
return requestHandleCounter;
|
|
25
|
+
}
|
|
4
26
|
var ServiceBase = class {
|
|
5
27
|
constructor(authToken, secureChannel) {
|
|
6
28
|
this.authToken = authToken;
|
|
7
29
|
this.secureChannel = secureChannel;
|
|
8
30
|
}
|
|
9
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Validates the `serviceResult` value from a response header.
|
|
33
|
+
*
|
|
34
|
+
* Throws `SessionInvalidError` for session-related status codes so callers
|
|
35
|
+
* can detect a dropped session and act accordingly (e.g. reconnect).
|
|
36
|
+
* Throws a generic `Error` for all other non-Good codes.
|
|
37
|
+
*
|
|
38
|
+
* @param result - The `serviceResult` value from the response header.
|
|
39
|
+
* @param context - Short description used in the error message (e.g. "ReadRequest").
|
|
40
|
+
*/
|
|
41
|
+
checkServiceResult(result, context) {
|
|
42
|
+
if (result === void 0 || result === StatusCode.Good) return;
|
|
43
|
+
if (result === StatusCode.BadSessionIdInvalid || result === StatusCode.BadSessionClosed) {
|
|
44
|
+
throw new SessionInvalidError(result);
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`${context} failed: ${StatusCodeToString(result)} (${StatusCodeToStringNumber(result)})`);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Builds a RequestHeader for an outgoing service request.
|
|
50
|
+
*
|
|
51
|
+
* @param returnDiagnostics - Bitmask of diagnostic fields to request from the
|
|
52
|
+
* server (OPC UA Part 4, §7.15). Use {@link ReturnDiagnosticsMask} constants
|
|
53
|
+
* to compose the value. Default `0` = no diagnostics.
|
|
54
|
+
*/
|
|
55
|
+
createRequestHeader(returnDiagnostics = 0, preAllocatedHandle) {
|
|
10
56
|
const requestHeader = new RequestHeader();
|
|
11
57
|
requestHeader.authenticationToken = this.authToken;
|
|
12
58
|
requestHeader.timestamp = /* @__PURE__ */ new Date();
|
|
13
|
-
requestHeader.requestHandle =
|
|
14
|
-
requestHeader.returnDiagnostics =
|
|
59
|
+
requestHeader.requestHandle = preAllocatedHandle ?? nextRequestHandle();
|
|
60
|
+
requestHeader.returnDiagnostics = returnDiagnostics;
|
|
15
61
|
requestHeader.auditEntryId = "";
|
|
16
62
|
requestHeader.timeoutHint = 6e4;
|
|
17
63
|
requestHeader.additionalHeader = ExtensionObject.newEmpty();
|
|
18
64
|
return requestHeader;
|
|
19
65
|
}
|
|
20
66
|
};
|
|
67
|
+
var CertificateRequiredError = class extends Error {
|
|
68
|
+
statusCode;
|
|
69
|
+
constructor(statusCode) {
|
|
70
|
+
super(
|
|
71
|
+
`Server requires a client certificate: ${StatusCodeToString(statusCode)} (0x${statusCode.toString(16).toUpperCase().padStart(8, "0")})`
|
|
72
|
+
);
|
|
73
|
+
this.name = "CertificateRequiredError";
|
|
74
|
+
this.statusCode = statusCode;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
var CERTIFICATE_REQUIRED_STATUS_CODES = /* @__PURE__ */ new Set([
|
|
78
|
+
2148663296,
|
|
79
|
+
// BadCertificateInvalid
|
|
80
|
+
2148728832,
|
|
81
|
+
// BadSecurityChecksFailed
|
|
82
|
+
2153316352
|
|
83
|
+
// BadNoValidCertificates
|
|
84
|
+
]);
|
|
21
85
|
|
|
22
86
|
// src/services/sessionService.ts
|
|
23
87
|
var SessionService = class _SessionService extends ServiceBase {
|
|
@@ -25,8 +89,20 @@ var SessionService = class _SessionService extends ServiceBase {
|
|
|
25
89
|
super(authToken, secureChannel);
|
|
26
90
|
this.configuration = configuration;
|
|
27
91
|
}
|
|
28
|
-
|
|
29
|
-
|
|
92
|
+
logger = getLogger("services.SessionService");
|
|
93
|
+
/**
|
|
94
|
+
* Creates a new session on the server (OPC UA Part 4, Section 5.7.2).
|
|
95
|
+
*
|
|
96
|
+
* @param clientCertificate - Optional DER-encoded client certificate to include in
|
|
97
|
+
* the request. Pass `null` (default) for SecurityPolicy None without a cert.
|
|
98
|
+
* When the server rejects a `null`-cert request with a certificate-related status
|
|
99
|
+
* code, the caller should retry with a `Uint8Array` certificate (OPC UA 1.0
|
|
100
|
+
* fallback — see `CertificateRequiredError`).
|
|
101
|
+
* @returns The session ID, authentication token, and selected server endpoint.
|
|
102
|
+
* @throws {CertificateRequiredError} when the server demands a client certificate.
|
|
103
|
+
*/
|
|
104
|
+
async createSession(clientCertificate = null) {
|
|
105
|
+
this.logger.debug("Creating session...");
|
|
30
106
|
const clientDescription = new ApplicationDescription();
|
|
31
107
|
clientDescription.applicationUri = this.configuration.applicationUri;
|
|
32
108
|
clientDescription.productUri = this.configuration.productUri;
|
|
@@ -42,10 +118,10 @@ var SessionService = class _SessionService extends ServiceBase {
|
|
|
42
118
|
request.endpointUrl = this.secureChannel.getEndpointUrl();
|
|
43
119
|
request.sessionName = "";
|
|
44
120
|
request.clientNonce = null;
|
|
45
|
-
request.clientCertificate = null;
|
|
121
|
+
request.clientCertificate = clientCertificate ?? null;
|
|
46
122
|
request.requestedSessionTimeout = 6e4;
|
|
47
123
|
request.maxResponseMessageSize = 0;
|
|
48
|
-
|
|
124
|
+
this.logger.debug("Sending CreateSessionRequest...");
|
|
49
125
|
const response = await this.secureChannel.issueServiceRequest(request);
|
|
50
126
|
if (!response || !(response instanceof CreateSessionResponse)) {
|
|
51
127
|
throw new Error("Invalid response type for CreateSessionRequest");
|
|
@@ -54,24 +130,41 @@ var SessionService = class _SessionService extends ServiceBase {
|
|
|
54
130
|
if (!castedResponse || !castedResponse.sessionId || !castedResponse.authenticationToken) {
|
|
55
131
|
throw new Error("CreateSessionResponse missing SessionId or AuthenticationToken");
|
|
56
132
|
}
|
|
57
|
-
const
|
|
58
|
-
|
|
133
|
+
const serviceResult = castedResponse.responseHeader?.serviceResult;
|
|
134
|
+
if (serviceResult !== void 0 && serviceResult !== StatusCode.Good) {
|
|
135
|
+
if (CERTIFICATE_REQUIRED_STATUS_CODES.has(serviceResult)) {
|
|
136
|
+
throw new CertificateRequiredError(serviceResult);
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`CreateSessionRequest failed: ${StatusCodeToString(serviceResult)}`);
|
|
139
|
+
}
|
|
140
|
+
const clientConnectionUrl = new URL("opc." + this.secureChannel.getEndpointUrl());
|
|
59
141
|
const securityMode = this.secureChannel.getSecurityMode();
|
|
60
142
|
const securityPolicyUri = this.secureChannel.getSecurityPolicy();
|
|
143
|
+
const normalizeEndpointUrl = (serverEndpointUrl) => {
|
|
144
|
+
const url = new URL(serverEndpointUrl);
|
|
145
|
+
url.hostname = clientConnectionUrl.hostname;
|
|
146
|
+
url.port = clientConnectionUrl.port;
|
|
147
|
+
return url;
|
|
148
|
+
};
|
|
61
149
|
const serverEndpoint = castedResponse?.serverEndpoints?.find((currentEndpoint) => {
|
|
62
|
-
const
|
|
63
|
-
return
|
|
150
|
+
const normalized = normalizeEndpointUrl(currentEndpoint.endpointUrl);
|
|
151
|
+
return normalized.protocol === clientConnectionUrl.protocol && normalized.pathname === clientConnectionUrl.pathname && currentEndpoint.securityMode === securityMode && currentEndpoint.securityPolicyUri === securityPolicyUri;
|
|
64
152
|
});
|
|
65
153
|
if (!serverEndpoint) {
|
|
66
|
-
throw new Error(`Server endpoint ${
|
|
154
|
+
throw new Error(`Server endpoint ${clientConnectionUrl.toString()} not found in CreateSessionResponse`);
|
|
67
155
|
}
|
|
68
|
-
|
|
156
|
+
serverEndpoint.endpointUrl = normalizeEndpointUrl(serverEndpoint.endpointUrl).toString();
|
|
157
|
+
this.logger.debug("Session created with id:", castedResponse.sessionId.identifier);
|
|
69
158
|
return {
|
|
70
159
|
sessionId: castedResponse.sessionId.identifier,
|
|
71
160
|
authToken: castedResponse.authenticationToken,
|
|
72
161
|
endpoint: serverEndpoint
|
|
73
162
|
};
|
|
74
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Activates an existing session using the supplied identity token (OPC UA Part 4, Section 5.7.3).
|
|
166
|
+
* @param identityToken - User identity token (anonymous, username/password, certificate, or issued token).
|
|
167
|
+
*/
|
|
75
168
|
async activateSession(identityToken) {
|
|
76
169
|
const signatureData = new SignatureData();
|
|
77
170
|
signatureData.algorithm = this.secureChannel.getSecurityPolicy();
|
|
@@ -83,16 +176,65 @@ var SessionService = class _SessionService extends ServiceBase {
|
|
|
83
176
|
request.localeIds = ["en-US"];
|
|
84
177
|
request.userIdentityToken = ExtensionObject.newBinary(identityToken);
|
|
85
178
|
request.userTokenSignature = signatureData;
|
|
86
|
-
|
|
87
|
-
await this.secureChannel.issueServiceRequest(request);
|
|
88
|
-
|
|
179
|
+
this.logger.debug("Sending ActivateSessionRequest...");
|
|
180
|
+
const activateResponse = await this.secureChannel.issueServiceRequest(request);
|
|
181
|
+
const activateResult = activateResponse?.responseHeader?.serviceResult;
|
|
182
|
+
if (activateResult !== void 0 && activateResult !== StatusCode.Good) {
|
|
183
|
+
throw new Error(`ActivateSessionRequest failed: ${StatusCodeToString(activateResult)}`);
|
|
184
|
+
}
|
|
185
|
+
this.logger.debug("Session activated.");
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Sends a CancelRequest to the server asking it to abandon the pending
|
|
189
|
+
* service request identified by `requestHandle` (OPC UA Part 4, Section 5.7.5).
|
|
190
|
+
*
|
|
191
|
+
* The server makes a best-effort attempt to cancel matching requests.
|
|
192
|
+
* Cancelled requests complete with a status of `BadRequestCancelledByClient`.
|
|
193
|
+
*
|
|
194
|
+
* @param requestHandle - The `requestHeader.requestHandle` value of the pending
|
|
195
|
+
* request to cancel. Pass `0` to attempt to cancel all outstanding requests
|
|
196
|
+
* (server behaviour for handle 0 is implementation-specific).
|
|
197
|
+
* @returns The number of pending requests that were actually cancelled
|
|
198
|
+
* (`CancelResponse.cancelCount`).
|
|
199
|
+
*/
|
|
200
|
+
async cancel(requestHandle) {
|
|
201
|
+
this.logger.debug(`Sending CancelRequest for requestHandle ${requestHandle}...`);
|
|
202
|
+
const request = new CancelRequest();
|
|
203
|
+
request.requestHeader = this.createRequestHeader();
|
|
204
|
+
request.requestHandle = requestHandle;
|
|
205
|
+
const response = await this.secureChannel.issueServiceRequest(request);
|
|
206
|
+
const result = response?.responseHeader?.serviceResult;
|
|
207
|
+
if (result !== void 0 && result !== StatusCode.Good) {
|
|
208
|
+
throw new Error(`CancelRequest failed: ${StatusCodeToString(result)}`);
|
|
209
|
+
}
|
|
210
|
+
const cancelCount = response?.cancelCount ?? 0;
|
|
211
|
+
this.logger.debug(`CancelRequest completed; cancelCount = ${cancelCount}.`);
|
|
212
|
+
return cancelCount;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Closes the current session on the server (OPC UA Part 4, Section 5.7.4).
|
|
216
|
+
* @param deleteSubscriptions - When true the server deletes all Subscriptions
|
|
217
|
+
* associated with this Session, freeing their resources immediately.
|
|
218
|
+
* Pass false to keep Subscriptions alive for transfer to another Session.
|
|
219
|
+
*/
|
|
220
|
+
async closeSession(deleteSubscriptions) {
|
|
221
|
+
this.logger.debug("Sending CloseSessionRequest...");
|
|
222
|
+
const request = new CloseSessionRequest();
|
|
223
|
+
request.requestHeader = this.createRequestHeader();
|
|
224
|
+
request.deleteSubscriptions = deleteSubscriptions;
|
|
225
|
+
const response = await this.secureChannel.issueServiceRequest(request);
|
|
226
|
+
const result = response?.responseHeader?.serviceResult;
|
|
227
|
+
if (result !== void 0 && result !== StatusCode.Good) {
|
|
228
|
+
throw new Error(`CloseSession failed: ${StatusCodeToString(result)}`);
|
|
229
|
+
}
|
|
230
|
+
this.logger.debug("Session closed.");
|
|
89
231
|
}
|
|
90
232
|
recreate(authToken) {
|
|
91
233
|
return new _SessionService(authToken, this.secureChannel, this.configuration);
|
|
92
234
|
}
|
|
93
235
|
};
|
|
94
236
|
|
|
95
|
-
// src/issuerConfiguration.ts
|
|
237
|
+
// src/configuration/issuerConfiguration.ts
|
|
96
238
|
var IssuerConfiguration = class _IssuerConfiguration {
|
|
97
239
|
constructor(resourceId, authorityUrl, authorityProfileUri, tokenEndpoint, authorizationEndpoint, requestTypes, scopes) {
|
|
98
240
|
this.resourceId = resourceId;
|
|
@@ -148,29 +290,159 @@ var Session = class {
|
|
|
148
290
|
getAuthToken() {
|
|
149
291
|
return this.authToken;
|
|
150
292
|
}
|
|
293
|
+
getSessionId() {
|
|
294
|
+
return this.sessionId;
|
|
295
|
+
}
|
|
296
|
+
getEndpoint() {
|
|
297
|
+
return this.endpoint;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Switches the active user identity for this session by calling ActivateSession with
|
|
301
|
+
* a new identity token (OPC UA Part 4, Section 5.7.3 — Session Client Impersonate
|
|
302
|
+
* conformance unit).
|
|
303
|
+
*
|
|
304
|
+
* The server re-evaluates authorisation for the session under the new identity.
|
|
305
|
+
* All existing Subscriptions and MonitoredItems are preserved; only the security
|
|
306
|
+
* context changes.
|
|
307
|
+
*
|
|
308
|
+
* @param identity - The new user identity to apply to the session.
|
|
309
|
+
* @throws When the server returns a non-Good ServiceResult (e.g. `BadIdentityTokenRejected`
|
|
310
|
+
* or `BadUserAccessDenied`).
|
|
311
|
+
*/
|
|
312
|
+
async impersonate(identity) {
|
|
313
|
+
await this.activateSession(identity);
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Closes the session on the server (OPC UA Part 4, Section 5.7.4).
|
|
317
|
+
* @param deleteSubscriptions - When true the server deletes all Subscriptions
|
|
318
|
+
* tied to this Session. Defaults to true.
|
|
319
|
+
*/
|
|
320
|
+
async close(deleteSubscriptions = true) {
|
|
321
|
+
await this.sessionServices.closeSession(deleteSubscriptions);
|
|
322
|
+
}
|
|
151
323
|
};
|
|
152
324
|
|
|
153
325
|
// src/sessions/sessionHandler.ts
|
|
154
326
|
var SessionHandler = class {
|
|
327
|
+
constructor(secureChannel, configuration) {
|
|
328
|
+
this.configuration = configuration;
|
|
329
|
+
this.sessionServices = new SessionService(NodeId.newTwoByte(0), secureChannel, configuration);
|
|
330
|
+
}
|
|
155
331
|
sessionServices;
|
|
332
|
+
logger = getLogger("sessions.SessionHandler");
|
|
156
333
|
async createNewSession(identity) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
334
|
+
let sessionResult;
|
|
335
|
+
try {
|
|
336
|
+
sessionResult = await this.sessionServices.createSession(null);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
if (err instanceof CertificateRequiredError) {
|
|
339
|
+
const fallbackCert = this.configuration.securityConfiguration?.applicationInstanceCertificate;
|
|
340
|
+
if (fallbackCert) {
|
|
341
|
+
this.logger.info(
|
|
342
|
+
"Server requires a client certificate (OPC UA 1.0 fallback); retrying CreateSession with applicationInstanceCertificate."
|
|
343
|
+
);
|
|
344
|
+
sessionResult = await this.sessionServices.createSession(fallbackCert);
|
|
345
|
+
} else {
|
|
346
|
+
this.logger.warn(
|
|
347
|
+
"Server requires a client certificate but no applicationInstanceCertificate is configured in securityConfiguration. Cannot complete the 1.0 fallback."
|
|
348
|
+
);
|
|
349
|
+
throw err;
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
throw err;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
this.sessionServices = this.sessionServices.recreate(sessionResult.authToken);
|
|
356
|
+
const session = new Session(sessionResult.sessionId, sessionResult.authToken, sessionResult.endpoint, this.sessionServices);
|
|
357
|
+
this.validateUserTokenPolicy(identity, sessionResult.endpoint);
|
|
160
358
|
await session.activateSession(identity);
|
|
161
359
|
return session;
|
|
162
360
|
}
|
|
163
|
-
|
|
164
|
-
|
|
361
|
+
/**
|
|
362
|
+
* Attempts to reactivate an existing OPC UA session on the current (new) SecureChannel
|
|
363
|
+
* without calling CreateSession first (OPC UA Part 4, Section 5.7.3).
|
|
364
|
+
*
|
|
365
|
+
* This is the preferred recovery path when the SecureChannel drops but the server-side
|
|
366
|
+
* session has not yet timed out: only a new channel is needed, not a new session.
|
|
367
|
+
*
|
|
368
|
+
* @returns The reactivated Session if ActivateSession succeeded, or `null` if the server
|
|
369
|
+
* rejected the request (e.g. the session had already expired).
|
|
370
|
+
*/
|
|
371
|
+
async tryActivateExistingSession(existingAuthToken, existingSessionId, existingEndpoint, identity) {
|
|
372
|
+
const serviceForExistingSession = this.sessionServices.recreate(existingAuthToken);
|
|
373
|
+
try {
|
|
374
|
+
const session = new Session(existingSessionId, existingAuthToken, existingEndpoint, serviceForExistingSession);
|
|
375
|
+
await session.activateSession(identity);
|
|
376
|
+
return session;
|
|
377
|
+
} catch (err) {
|
|
378
|
+
this.logger.debug("ActivateSession for existing session failed:", err);
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Closes the active session on the server (OPC UA Part 4, Section 5.7.4).
|
|
384
|
+
* @param deleteSubscriptions - Forwarded to CloseSessionRequest. Defaults to true.
|
|
385
|
+
*/
|
|
386
|
+
async closeSession(deleteSubscriptions = true) {
|
|
387
|
+
await this.sessionServices.closeSession(deleteSubscriptions);
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Sends a CancelRequest to the server to abandon a pending service call
|
|
391
|
+
* (OPC UA Part 4, Section 5.7.5).
|
|
392
|
+
*
|
|
393
|
+
* @param requestHandle - The `requestHandle` from the `RequestHeader` of the
|
|
394
|
+
* pending request to cancel.
|
|
395
|
+
* @returns The number of requests the server actually cancelled.
|
|
396
|
+
*/
|
|
397
|
+
async cancel(requestHandle) {
|
|
398
|
+
return this.sessionServices.cancel(requestHandle);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Validates the requested user-identity token type against:
|
|
402
|
+
* 1. The `allowedUserTokenTypes` from the client security configuration — the
|
|
403
|
+
* client has explicitly restricted which token types it will use.
|
|
404
|
+
* 2. The token policies advertised by the server endpoint — verifies that the
|
|
405
|
+
* server actually supports at least one of the allowed types.
|
|
406
|
+
*
|
|
407
|
+
* Throws with a descriptive message if either check fails.
|
|
408
|
+
*/
|
|
409
|
+
validateUserTokenPolicy(identity, endpoint) {
|
|
410
|
+
const allowedTypes = this.configuration.securityConfiguration?.allowedUserTokenTypes;
|
|
411
|
+
if (!allowedTypes) return;
|
|
412
|
+
const requestedType = identity.getTokenType();
|
|
413
|
+
if (!allowedTypes.includes(requestedType)) {
|
|
414
|
+
throw new Error(
|
|
415
|
+
`User token type '${UserTokenTypeEnum[requestedType]}' is not permitted by the client security configuration. Allowed types: ${allowedTypes.map((t) => UserTokenTypeEnum[t]).join(", ")}.`
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
const serverTypes = endpoint.userIdentityTokens?.map((p) => p.tokenType) ?? [];
|
|
419
|
+
const intersection = allowedTypes.filter((t) => serverTypes.includes(t));
|
|
420
|
+
if (intersection.length === 0) {
|
|
421
|
+
throw new Error(
|
|
422
|
+
`Server endpoint does not offer any user token type from the allowed list: ${allowedTypes.map((t) => UserTokenTypeEnum[t]).join(", ")}. Server offers: ${serverTypes.map((t) => UserTokenTypeEnum[t]).join(", ")}.`
|
|
423
|
+
);
|
|
424
|
+
}
|
|
165
425
|
}
|
|
166
426
|
};
|
|
167
427
|
|
|
428
|
+
// src/securityConfiguration.ts
|
|
429
|
+
var SECURITY_POLICY_NONE_URI = "http://opcfoundation.org/UA/SecurityPolicy#None";
|
|
430
|
+
|
|
168
431
|
// src/services/attributeServiceAttributes.ts
|
|
169
432
|
var AttrIdValue = 13;
|
|
170
433
|
|
|
171
434
|
// src/services/attributeService.ts
|
|
172
435
|
var AttributeService = class extends ServiceBase {
|
|
173
|
-
|
|
436
|
+
logger = getLogger("services.AttributeService");
|
|
437
|
+
/**
|
|
438
|
+
* Reads the Value attribute of one or more Nodes (OPC UA Part 4, Section 5.10.2).
|
|
439
|
+
* @param nodeIds - NodeIds of the Nodes to read.
|
|
440
|
+
* @param maxAge - Maximum age of the cached value in milliseconds the server may return. 0 = always current value.
|
|
441
|
+
* @param timestampsToReturn - Which timestamps to include in results. Default: Source.
|
|
442
|
+
* @param returnDiagnostics - Bitmask of diagnostic fields to request (OPC UA Part 4, §7.15). Default: 0.
|
|
443
|
+
* @returns Array of results containing value, raw status code, and optional diagnostic info, one per requested NodeId.
|
|
444
|
+
*/
|
|
445
|
+
async ReadValue(nodeIds, maxAge = 0, timestampsToReturn = TimestampsToReturnEnum.Source, returnDiagnostics = 0, requestHandle) {
|
|
174
446
|
const readValueIds = nodeIds.map((ni) => {
|
|
175
447
|
const readValueId = new ReadValueId();
|
|
176
448
|
readValueId.nodeId = ni;
|
|
@@ -180,19 +452,22 @@ var AttributeService = class extends ServiceBase {
|
|
|
180
452
|
return readValueId;
|
|
181
453
|
});
|
|
182
454
|
const request = new ReadRequest();
|
|
183
|
-
request.requestHeader = this.createRequestHeader();
|
|
184
|
-
request.maxAge =
|
|
185
|
-
request.timestampsToReturn =
|
|
455
|
+
request.requestHeader = this.createRequestHeader(returnDiagnostics, requestHandle);
|
|
456
|
+
request.maxAge = maxAge;
|
|
457
|
+
request.timestampsToReturn = timestampsToReturn;
|
|
186
458
|
request.nodesToRead = readValueIds;
|
|
187
|
-
|
|
459
|
+
this.logger.debug("Sending ReadRequest...");
|
|
188
460
|
const response = await this.secureChannel.issueServiceRequest(request);
|
|
461
|
+
this.checkServiceResult(response.responseHeader?.serviceResult, "ReadRequest");
|
|
189
462
|
const results = new Array();
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
463
|
+
const diagInfos = response.diagnosticInfos ?? [];
|
|
464
|
+
for (let i = 0; i < (response.results ?? []).length; i++) {
|
|
465
|
+
const dataValue = response.results[i];
|
|
466
|
+
results.push({
|
|
467
|
+
statusCode: dataValue.statusCode ?? StatusCode.Good,
|
|
468
|
+
value: dataValue.value,
|
|
469
|
+
diagnosticInfo: diagInfos[i]
|
|
470
|
+
});
|
|
196
471
|
}
|
|
197
472
|
return results;
|
|
198
473
|
}
|
|
@@ -203,13 +478,14 @@ var AttributeService = class extends ServiceBase {
|
|
|
203
478
|
|
|
204
479
|
// src/readValueResult.ts
|
|
205
480
|
var ReadValueResult = class {
|
|
206
|
-
constructor(value,
|
|
481
|
+
constructor(value, statusCode, diagnosticInfo) {
|
|
207
482
|
this.value = value;
|
|
208
|
-
this.
|
|
483
|
+
this.statusCode = statusCode;
|
|
484
|
+
this.diagnosticInfo = diagnosticInfo;
|
|
209
485
|
}
|
|
210
486
|
};
|
|
211
487
|
|
|
212
|
-
// src/subscriptionHandlerEntry.ts
|
|
488
|
+
// src/subscription/subscriptionHandlerEntry.ts
|
|
213
489
|
var SubscriptionHandlerEntry = class {
|
|
214
490
|
constructor(subscriptionId, handle, id, callback) {
|
|
215
491
|
this.subscriptionId = subscriptionId;
|
|
@@ -219,95 +495,155 @@ var SubscriptionHandlerEntry = class {
|
|
|
219
495
|
}
|
|
220
496
|
};
|
|
221
497
|
|
|
222
|
-
// src/subscriptionHandler.ts
|
|
498
|
+
// src/subscription/subscriptionHandler.ts
|
|
499
|
+
var NODE_ID_DATA_CHANGE_NOTIFICATION = 811;
|
|
500
|
+
var NODE_ID_STATUS_CHANGE_NOTIFICATION = 818;
|
|
223
501
|
var SubscriptionHandler = class {
|
|
224
502
|
constructor(subscriptionService, monitoredItemService) {
|
|
225
503
|
this.subscriptionService = subscriptionService;
|
|
226
504
|
this.monitoredItemService = monitoredItemService;
|
|
227
505
|
}
|
|
506
|
+
logger = getLogger("SubscriptionHandler");
|
|
228
507
|
entries = new Array();
|
|
229
508
|
nextHandle = 0;
|
|
230
|
-
|
|
509
|
+
isRunning = false;
|
|
510
|
+
/** Guards against multiple concurrent publish loops. */
|
|
511
|
+
publishInFlight = false;
|
|
512
|
+
/**
|
|
513
|
+
* Optional callback invoked when the server announces a shutdown via a
|
|
514
|
+
* `StatusChangeNotification` with status `BadShutdown` or `BadServerHalted`
|
|
515
|
+
* (OPC UA Part 4, §5.13.6.2 — Session Client Detect Shutdown).
|
|
516
|
+
*
|
|
517
|
+
* Assign this before calling `subscribe()`. The client sets it automatically
|
|
518
|
+
* in `initServices()` to trigger a reconnect.
|
|
519
|
+
*/
|
|
520
|
+
onShutdown;
|
|
521
|
+
/** Returns true when at least one subscription is active and the publish loop is running. */
|
|
522
|
+
hasActiveSubscription() {
|
|
523
|
+
return this.isRunning && this.entries.length > 0;
|
|
524
|
+
}
|
|
525
|
+
async subscribe(ids, callback, options) {
|
|
231
526
|
if (this.entries.length > 0) {
|
|
232
527
|
throw new Error("Subscribing more than once is not implemented");
|
|
233
528
|
}
|
|
234
|
-
const subscriptionId = await this.subscriptionService.createSubscription();
|
|
529
|
+
const subscriptionId = await this.subscriptionService.createSubscription(options);
|
|
235
530
|
const items = [];
|
|
236
|
-
for (
|
|
237
|
-
const entry = new SubscriptionHandlerEntry(
|
|
238
|
-
subscriptionId,
|
|
239
|
-
this.nextHandle++,
|
|
240
|
-
id,
|
|
241
|
-
callback
|
|
242
|
-
);
|
|
531
|
+
for (const id of ids) {
|
|
532
|
+
const entry = new SubscriptionHandlerEntry(subscriptionId, this.nextHandle++, id, callback);
|
|
243
533
|
this.entries.push(entry);
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
534
|
+
items.push({ id, handle: entry.handle });
|
|
535
|
+
}
|
|
536
|
+
await this.monitoredItemService.createMonitoredItems(subscriptionId, items, options);
|
|
537
|
+
this.isRunning = true;
|
|
538
|
+
void this.publishLoop([]);
|
|
539
|
+
}
|
|
540
|
+
// https://reference.opcfoundation.org/Core/Part4/v105/docs/5.14.5
|
|
541
|
+
async publishLoop(pendingAcknowledgements) {
|
|
542
|
+
if (!this.isRunning) return;
|
|
543
|
+
if (this.publishInFlight) return;
|
|
544
|
+
this.publishInFlight = true;
|
|
545
|
+
let response;
|
|
546
|
+
try {
|
|
547
|
+
response = await this.subscriptionService.publish(pendingAcknowledgements);
|
|
548
|
+
} catch (err) {
|
|
549
|
+
this.logger.error(`Publish failed, stopping publish loop: ${err}`);
|
|
550
|
+
this.isRunning = false;
|
|
551
|
+
this.publishInFlight = false;
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
this.publishInFlight = false;
|
|
555
|
+
const { subscriptionId, availableSequenceNumbers, moreNotifications, notificationMessage } = response;
|
|
556
|
+
const notificationDatas = notificationMessage?.notificationData ?? [];
|
|
557
|
+
const seqNumber = notificationMessage?.sequenceNumber;
|
|
558
|
+
const nextAcknowledgements = [];
|
|
559
|
+
const isKeepAlive = notificationDatas.length === 0;
|
|
560
|
+
if (!isKeepAlive && seqNumber !== void 0) {
|
|
561
|
+
const isAvailable = !availableSequenceNumbers || availableSequenceNumbers.includes(seqNumber);
|
|
562
|
+
if (isAvailable) {
|
|
563
|
+
const ack = new SubscriptionAcknowledgement();
|
|
564
|
+
ack.subscriptionId = subscriptionId;
|
|
565
|
+
ack.sequenceNumber = seqNumber;
|
|
566
|
+
nextAcknowledgements.push(ack);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
for (const notificationData of notificationDatas) {
|
|
265
570
|
const decodedData = notificationData.data;
|
|
266
|
-
const
|
|
267
|
-
|
|
571
|
+
const rawTypeId = notificationData.typeId;
|
|
572
|
+
const typeNodeId = rawTypeId instanceof ExpandedNodeId ? rawTypeId.nodeId : rawTypeId;
|
|
573
|
+
if (typeNodeId.namespace === 0 && typeNodeId.identifier === NODE_ID_DATA_CHANGE_NOTIFICATION) {
|
|
268
574
|
const dataChangeNotification = decodedData;
|
|
269
|
-
for (
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
const entry = this.entries.find((e) => e.handle == clientHandle);
|
|
273
|
-
entry?.callback([{
|
|
274
|
-
id: entry.id,
|
|
275
|
-
value: value.value?.value
|
|
276
|
-
}]);
|
|
575
|
+
for (const item of dataChangeNotification.monitoredItems) {
|
|
576
|
+
const entry = this.entries.find((e) => e.handle === item.clientHandle);
|
|
577
|
+
entry?.callback([{ id: entry.id, value: item.value.value?.value }]);
|
|
277
578
|
}
|
|
579
|
+
} else if (typeNodeId.namespace === 0 && typeNodeId.identifier === NODE_ID_STATUS_CHANGE_NOTIFICATION) {
|
|
580
|
+
const statusChange = decodedData;
|
|
581
|
+
this.logger.warn(
|
|
582
|
+
`Subscription ${subscriptionId} status changed: 0x${statusChange.status?.toString(16).toUpperCase()}`
|
|
583
|
+
);
|
|
584
|
+
this.isRunning = false;
|
|
585
|
+
const status = statusChange.status;
|
|
586
|
+
if (status === StatusCode.BadShutdown || status === StatusCode.BadServerHalted) {
|
|
587
|
+
this.logger.warn("Server shutdown announced via StatusChangeNotification \u2014 triggering reconnect.");
|
|
588
|
+
this.onShutdown?.();
|
|
589
|
+
}
|
|
590
|
+
return;
|
|
278
591
|
} else {
|
|
279
|
-
|
|
592
|
+
this.logger.warn(
|
|
593
|
+
`Notification data type ${typeNodeId.namespace}:${typeNodeId.identifier} is not supported.`
|
|
594
|
+
);
|
|
280
595
|
}
|
|
281
596
|
}
|
|
282
|
-
|
|
283
|
-
this.
|
|
284
|
-
}
|
|
597
|
+
if (moreNotifications) {
|
|
598
|
+
void this.publishLoop(nextAcknowledgements);
|
|
599
|
+
} else {
|
|
600
|
+
setTimeout(() => void this.publishLoop(nextAcknowledgements), 0);
|
|
601
|
+
}
|
|
285
602
|
}
|
|
286
603
|
};
|
|
287
604
|
var SubscriptionService = class extends ServiceBase {
|
|
605
|
+
logger = getLogger("services.SubscriptionService");
|
|
606
|
+
/**
|
|
607
|
+
* Creates a new subscription on the server (OPC UA Part 4, Section 5.14.2).
|
|
608
|
+
* @param options - Optional subscription parameters; server revises all values.
|
|
609
|
+
* @returns The server-assigned subscription ID.
|
|
610
|
+
*/
|
|
288
611
|
// https://reference.opcfoundation.org/Core/Part4/v105/docs/5.14.2
|
|
289
|
-
async createSubscription() {
|
|
612
|
+
async createSubscription(options) {
|
|
290
613
|
const request = new CreateSubscriptionRequest();
|
|
291
614
|
request.requestHeader = this.createRequestHeader();
|
|
292
|
-
request.requestedPublishingInterval = 2e3;
|
|
293
|
-
request.requestedLifetimeCount = 36e4;
|
|
294
|
-
request.requestedMaxKeepAliveCount = 6e4;
|
|
295
|
-
request.maxNotificationsPerPublish = 200;
|
|
615
|
+
request.requestedPublishingInterval = options?.requestedPublishingInterval ?? 2e3;
|
|
616
|
+
request.requestedLifetimeCount = options?.requestedLifetimeCount ?? 36e4;
|
|
617
|
+
request.requestedMaxKeepAliveCount = options?.requestedMaxKeepAliveCount ?? 6e4;
|
|
618
|
+
request.maxNotificationsPerPublish = options?.maxNotificationsPerPublish ?? 200;
|
|
296
619
|
request.publishingEnabled = true;
|
|
297
|
-
request.priority = 1;
|
|
298
|
-
|
|
620
|
+
request.priority = options?.priority ?? 1;
|
|
621
|
+
this.logger.debug("Sending CreateSubscriptionRequest...");
|
|
299
622
|
const response = await this.secureChannel.issueServiceRequest(request);
|
|
300
|
-
|
|
623
|
+
const serviceResult = response.responseHeader?.serviceResult;
|
|
624
|
+
if (serviceResult !== void 0 && serviceResult !== StatusCode.Good) {
|
|
625
|
+
throw new Error(`CreateSubscriptionRequest failed: ${StatusCodeToString(serviceResult)}`);
|
|
626
|
+
}
|
|
627
|
+
this.logger.debug(`Subscription created with id: ${response.subscriptionId}`);
|
|
301
628
|
return response.subscriptionId;
|
|
302
629
|
}
|
|
630
|
+
/**
|
|
631
|
+
* Sends a publish request to receive notifications from active subscriptions (OPC UA Part 4, Section 5.14.5).
|
|
632
|
+
* @param acknowledgements - Sequence numbers to acknowledge from previous publish responses.
|
|
633
|
+
* @returns The publish response containing notification data.
|
|
634
|
+
*/
|
|
303
635
|
// https://reference.opcfoundation.org/Core/Part4/v105/docs/5.14.5
|
|
304
636
|
async publish(acknowledgements) {
|
|
305
637
|
const request = new PublishRequest();
|
|
306
638
|
request.requestHeader = this.createRequestHeader();
|
|
307
639
|
request.subscriptionAcknowledgements = acknowledgements;
|
|
308
|
-
|
|
640
|
+
this.logger.debug("Sending PublishRequest...");
|
|
309
641
|
const response = await this.secureChannel.issueServiceRequest(request);
|
|
310
|
-
|
|
642
|
+
const serviceResult = response.responseHeader?.serviceResult;
|
|
643
|
+
if (serviceResult !== void 0 && serviceResult !== StatusCode.Good) {
|
|
644
|
+
throw new Error(`PublishRequest failed: ${StatusCodeToString(serviceResult)}`);
|
|
645
|
+
}
|
|
646
|
+
this.logger.debug("Received PublishResponse.");
|
|
311
647
|
return response;
|
|
312
648
|
}
|
|
313
649
|
constructor(authToken, secureChannel) {
|
|
@@ -315,7 +651,15 @@ var SubscriptionService = class extends ServiceBase {
|
|
|
315
651
|
}
|
|
316
652
|
};
|
|
317
653
|
var MonitoredItemService = class extends ServiceBase {
|
|
318
|
-
|
|
654
|
+
logger = getLogger("services.MonitoredItemService");
|
|
655
|
+
/**
|
|
656
|
+
* Creates monitored items within a subscription (OPC UA Part 4, Section 5.13.2).
|
|
657
|
+
* @param subscriptionId - ID of the subscription to add monitored items to.
|
|
658
|
+
* @param ids - Array of NodeIds and client handles identifying the items to monitor.
|
|
659
|
+
* @param options - Monitoring options (samplingInterval, queueSize).
|
|
660
|
+
*/
|
|
661
|
+
async createMonitoredItems(subscriptionId, ids, options = {}) {
|
|
662
|
+
const { samplingInterval = 1e3, queueSize = 100 } = options;
|
|
319
663
|
const items = ids.map((ni) => {
|
|
320
664
|
const readValueId = new ReadValueId();
|
|
321
665
|
readValueId.nodeId = ni.id;
|
|
@@ -324,9 +668,9 @@ var MonitoredItemService = class extends ServiceBase {
|
|
|
324
668
|
readValueId.dataEncoding = new QualifiedName(0, "");
|
|
325
669
|
const monitoringParameters = new MonitoringParameters();
|
|
326
670
|
monitoringParameters.clientHandle = ni.handle;
|
|
327
|
-
monitoringParameters.samplingInterval =
|
|
671
|
+
monitoringParameters.samplingInterval = samplingInterval;
|
|
328
672
|
monitoringParameters.filter = ExtensionObject.newEmpty();
|
|
329
|
-
monitoringParameters.queueSize =
|
|
673
|
+
monitoringParameters.queueSize = queueSize;
|
|
330
674
|
monitoringParameters.discardOldest = true;
|
|
331
675
|
const monitoredItemCreateRequest = new MonitoredItemCreateRequest();
|
|
332
676
|
monitoredItemCreateRequest.itemToMonitor = readValueId;
|
|
@@ -339,69 +683,935 @@ var MonitoredItemService = class extends ServiceBase {
|
|
|
339
683
|
request.subscriptionId = subscriptionId;
|
|
340
684
|
request.timestampsToReturn = TimestampsToReturnEnum.Source;
|
|
341
685
|
request.itemsToCreate = items;
|
|
342
|
-
|
|
343
|
-
await this.secureChannel.issueServiceRequest(request);
|
|
686
|
+
this.logger.debug("Sending CreateMonitoredItemsRequest...");
|
|
687
|
+
const response = await this.secureChannel.issueServiceRequest(request);
|
|
688
|
+
const serviceResult = response.responseHeader?.serviceResult;
|
|
689
|
+
if (serviceResult !== void 0 && serviceResult !== StatusCode.Good) {
|
|
690
|
+
throw new Error(`CreateMonitoredItemsRequest failed: ${StatusCodeToString(serviceResult)}`);
|
|
691
|
+
}
|
|
692
|
+
for (const result of response.results ?? []) {
|
|
693
|
+
if (result.statusCode !== void 0 && result.statusCode !== StatusCode.Good) {
|
|
694
|
+
this.logger.warn(`Failed to create monitored item: ${StatusCodeToString(result.statusCode)}`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
constructor(authToken, secureChannel) {
|
|
699
|
+
super(authToken, secureChannel);
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
var MethodService = class extends ServiceBase {
|
|
703
|
+
logger = getLogger("services.MethodService");
|
|
704
|
+
/**
|
|
705
|
+
* Calls one or more methods on the server (OPC UA Part 4, Section 5.11.2).
|
|
706
|
+
* @param methodsToCall - Array of CallMethodRequest describing each method to invoke.
|
|
707
|
+
* @param returnDiagnostics - Bitmask of diagnostic fields to request (OPC UA Part 4, §7.15). Default: 0.
|
|
708
|
+
* @returns Array of results containing output argument values, raw status code, and optional diagnostic info,
|
|
709
|
+
* one per requested method.
|
|
710
|
+
*/
|
|
711
|
+
async call(methodsToCall, returnDiagnostics = 0, requestHandle) {
|
|
712
|
+
const request = new CallRequest();
|
|
713
|
+
request.requestHeader = this.createRequestHeader(returnDiagnostics, requestHandle);
|
|
714
|
+
request.methodsToCall = methodsToCall;
|
|
715
|
+
this.logger.debug("Sending CallRequest...");
|
|
716
|
+
const response = await this.secureChannel.issueServiceRequest(request);
|
|
717
|
+
this.checkServiceResult(response.responseHeader?.serviceResult, "CallRequest");
|
|
718
|
+
const diagInfos = response.diagnosticInfos ?? [];
|
|
719
|
+
return response.results.map((result, i) => ({
|
|
720
|
+
statusCode: result.statusCode ?? StatusCode.Good,
|
|
721
|
+
value: result.outputArguments.map((arg) => arg.value),
|
|
722
|
+
diagnosticInfo: diagInfos[i]
|
|
723
|
+
}));
|
|
344
724
|
}
|
|
345
725
|
constructor(authToken, secureChannel) {
|
|
346
726
|
super(authToken, secureChannel);
|
|
347
727
|
}
|
|
348
728
|
};
|
|
349
729
|
|
|
730
|
+
// src/method/callMethodResult.ts
|
|
731
|
+
var CallMethodResult = class {
|
|
732
|
+
constructor(values, statusCode, diagnosticInfo) {
|
|
733
|
+
this.values = values;
|
|
734
|
+
this.statusCode = statusCode;
|
|
735
|
+
this.diagnosticInfo = diagnosticInfo;
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
var BrowseService = class extends ServiceBase {
|
|
739
|
+
logger = getLogger("services.BrowseService");
|
|
740
|
+
/**
|
|
741
|
+
* Browses one or more Nodes and returns their References (OPC UA Part 4, Section 5.9.2).
|
|
742
|
+
* @param nodesToBrowse - Array of BrowseDescriptions specifying nodes and filter criteria.
|
|
743
|
+
* @param returnDiagnostics - Bitmask of diagnostic fields to request (OPC UA Part 4, §7.15). Default: 0.
|
|
744
|
+
* @returns Array of BrowseResult, one per requested node.
|
|
745
|
+
*/
|
|
746
|
+
async browse(nodesToBrowse, returnDiagnostics = 0, requestHandle) {
|
|
747
|
+
const view = new ViewDescription();
|
|
748
|
+
view.viewId = NodeId.newNumeric(0, 0);
|
|
749
|
+
view.timestamp = /* @__PURE__ */ new Date(-116444736e5);
|
|
750
|
+
view.viewVersion = 0;
|
|
751
|
+
const request = new BrowseRequest();
|
|
752
|
+
request.requestHeader = this.createRequestHeader(returnDiagnostics, requestHandle);
|
|
753
|
+
request.view = view;
|
|
754
|
+
request.requestedMaxReferencesPerNode = 0;
|
|
755
|
+
request.nodesToBrowse = nodesToBrowse;
|
|
756
|
+
this.logger.debug("Sending BrowseRequest...");
|
|
757
|
+
const response = await this.secureChannel.issueServiceRequest(request);
|
|
758
|
+
this.checkServiceResult(response.responseHeader?.serviceResult, "BrowseRequest");
|
|
759
|
+
return response.results ?? [];
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Continues a Browse operation using continuation points (OPC UA Part 4, Section 5.9.3).
|
|
763
|
+
* @param continuationPoints - Continuation points returned by a prior Browse or BrowseNext call.
|
|
764
|
+
* @param releaseContinuationPoints - If true, releases the continuation points without returning results.
|
|
765
|
+
* @param returnDiagnostics - Bitmask of diagnostic fields to request (OPC UA Part 4, §7.15). Default: 0.
|
|
766
|
+
* @returns Array of BrowseResult, one per continuation point.
|
|
767
|
+
*/
|
|
768
|
+
async browseNext(continuationPoints, releaseContinuationPoints, returnDiagnostics = 0) {
|
|
769
|
+
const request = new BrowseNextRequest();
|
|
770
|
+
request.requestHeader = this.createRequestHeader(returnDiagnostics);
|
|
771
|
+
request.releaseContinuationPoints = releaseContinuationPoints;
|
|
772
|
+
request.continuationPoints = continuationPoints;
|
|
773
|
+
this.logger.debug("Sending BrowseNextRequest...");
|
|
774
|
+
const response = await this.secureChannel.issueServiceRequest(request);
|
|
775
|
+
this.checkServiceResult(response.responseHeader?.serviceResult, "BrowseNextRequest");
|
|
776
|
+
return response.results ?? [];
|
|
777
|
+
}
|
|
778
|
+
constructor(authToken, secureChannel) {
|
|
779
|
+
super(authToken, secureChannel);
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
// src/browseNodeResult.ts
|
|
784
|
+
var BrowseNodeResult = class {
|
|
785
|
+
constructor(referenceTypeId, isForward, nodeId, browseName, displayName, nodeClass, typeDefinition) {
|
|
786
|
+
this.referenceTypeId = referenceTypeId;
|
|
787
|
+
this.isForward = isForward;
|
|
788
|
+
this.nodeId = nodeId;
|
|
789
|
+
this.browseName = browseName;
|
|
790
|
+
this.displayName = displayName;
|
|
791
|
+
this.nodeClass = nodeClass;
|
|
792
|
+
this.typeDefinition = typeDefinition;
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
var NamespaceTable = class {
|
|
796
|
+
uris;
|
|
797
|
+
constructor(uris = []) {
|
|
798
|
+
this.uris = Object.freeze([...uris]);
|
|
799
|
+
}
|
|
800
|
+
/** Returns the namespace URI at the given index, or `undefined` if out of range. */
|
|
801
|
+
getUri(index) {
|
|
802
|
+
return this.uris[index];
|
|
803
|
+
}
|
|
804
|
+
/** Returns the namespace index for the given URI, or `undefined` if not found. */
|
|
805
|
+
getIndex(uri) {
|
|
806
|
+
const idx = this.uris.indexOf(uri);
|
|
807
|
+
return idx >= 0 ? idx : void 0;
|
|
808
|
+
}
|
|
809
|
+
/** Returns the full ordered array of namespace URIs. */
|
|
810
|
+
getUris() {
|
|
811
|
+
return this.uris;
|
|
812
|
+
}
|
|
813
|
+
/** Returns `true` when both tables contain the same URIs in the same order. */
|
|
814
|
+
equals(other) {
|
|
815
|
+
if (this.uris.length !== other.uris.length) return false;
|
|
816
|
+
return this.uris.every((uri, i) => uri === other.uris[i]);
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Remaps the namespace index of `nodeId` from this table to the equivalent
|
|
820
|
+
* index in `newTable` by matching namespace URIs.
|
|
821
|
+
*
|
|
822
|
+
* - Namespace index `0` (OPC UA base namespace) is always returned unchanged.
|
|
823
|
+
* - Returns the **same** `NodeId` instance when the index has not changed.
|
|
824
|
+
* - Returns a **new** `NodeId` instance with the updated index when remapping
|
|
825
|
+
* is required.
|
|
826
|
+
*
|
|
827
|
+
* @throws {Error} When the namespace URI at `nodeId.namespace` is not present
|
|
828
|
+
* in this (old) table or when the URI is not present in `newTable`.
|
|
829
|
+
*/
|
|
830
|
+
remapNodeId(nodeId, newTable) {
|
|
831
|
+
if (nodeId.namespace === 0) return nodeId;
|
|
832
|
+
const uri = this.getUri(nodeId.namespace);
|
|
833
|
+
if (uri === void 0) {
|
|
834
|
+
throw new Error(
|
|
835
|
+
`Cannot remap ${nodeId.toString()}: namespace index ${nodeId.namespace} is not present in the old NamespaceTable (length ${this.uris.length}).`
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
const newIndex = newTable.getIndex(uri);
|
|
839
|
+
if (newIndex === void 0) {
|
|
840
|
+
throw new Error(
|
|
841
|
+
`Cannot remap ${nodeId.toString()}: namespace URI '${uri}' is not present in the new NamespaceTable.`
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
if (newIndex === nodeId.namespace) return nodeId;
|
|
845
|
+
return new NodeId(newIndex, nodeId.identifier);
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
|
|
350
849
|
// src/client.ts
|
|
850
|
+
var SERVER_STATUS_NODE_ID = NodeId.newNumeric(0, 2256);
|
|
851
|
+
var NAMESPACE_ARRAY_NODE_ID = NodeId.newNumeric(0, 2255);
|
|
852
|
+
var ESTIMATED_RETURN_TIME_NODE_ID = NodeId.newNumeric(0, 2992);
|
|
853
|
+
var HAS_PROPERTY_REF_TYPE_ID = NodeId.newNumeric(0, 46);
|
|
854
|
+
var KEEP_ALIVE_INTERVAL_MS = 25e3;
|
|
855
|
+
var OPC_UA_MIN_DATE_TIME_MS = -116444736e5;
|
|
351
856
|
var Client = class {
|
|
352
857
|
constructor(endpointUrl, configuration, identity) {
|
|
353
858
|
this.configuration = configuration;
|
|
354
859
|
this.identity = identity;
|
|
355
860
|
this.endpointUrl = endpointUrl;
|
|
861
|
+
initLoggerProvider(configuration.loggerFactory);
|
|
862
|
+
this.logger = getLogger("Client");
|
|
356
863
|
}
|
|
357
864
|
endpointUrl;
|
|
358
|
-
|
|
865
|
+
attributeService;
|
|
866
|
+
methodService;
|
|
867
|
+
browseService;
|
|
359
868
|
session;
|
|
360
869
|
subscriptionHandler;
|
|
870
|
+
logger;
|
|
871
|
+
// Stored after connect() so that refreshSession() and disconnect() can use them.
|
|
872
|
+
secureChannel;
|
|
873
|
+
secureChannelFacade;
|
|
874
|
+
ws;
|
|
875
|
+
sessionHandler;
|
|
876
|
+
keepAliveTimer;
|
|
877
|
+
/** Set to true while a shutdown-triggered reconnect is pending to avoid duplicate attempts. */
|
|
878
|
+
shutdownReconnectPending = false;
|
|
879
|
+
/** Most recently read NamespaceArray from the server (Session Client Renew NodeIds). */
|
|
880
|
+
namespaceTable;
|
|
881
|
+
/**
|
|
882
|
+
* Called whenever the server's NamespaceArray changes after a session (re-)establishment
|
|
883
|
+
* (OPC UA Part 4, Section 5.7.1 — Session Client Renew NodeIds conformance unit).
|
|
884
|
+
*
|
|
885
|
+
* Use `oldTable.remapNodeId(nodeId, newTable)` to recalculate cached NodeIds.
|
|
886
|
+
*
|
|
887
|
+
* @example
|
|
888
|
+
* ```ts
|
|
889
|
+
* client.onNamespaceTableChanged = (oldTable, newTable) => {
|
|
890
|
+
* cachedNodeId = oldTable.remapNodeId(cachedNodeId, newTable)
|
|
891
|
+
* }
|
|
892
|
+
* ```
|
|
893
|
+
*/
|
|
894
|
+
onNamespaceTableChanged;
|
|
895
|
+
/**
|
|
896
|
+
* Called when the server sends `EstimatedReturnTime = MinDateTime`, indicating it does not
|
|
897
|
+
* expect to restart (OPC UA Part 5, Section 12.6 — Base Info Client Estimated Return Time
|
|
898
|
+
* conformance unit).
|
|
899
|
+
*
|
|
900
|
+
* When this fires the automatic reconnect is suppressed. The application is responsible for
|
|
901
|
+
* deciding whether to keep the `Client` instance or dispose it.
|
|
902
|
+
*
|
|
903
|
+
* @example
|
|
904
|
+
* ```ts
|
|
905
|
+
* client.onPermanentShutdown = () => {
|
|
906
|
+
* console.warn('Server will not restart — closing client.')
|
|
907
|
+
* }
|
|
908
|
+
* ```
|
|
909
|
+
*/
|
|
910
|
+
onPermanentShutdown;
|
|
361
911
|
getSession() {
|
|
362
912
|
if (!this.session) {
|
|
363
913
|
throw new Error("No session available");
|
|
364
914
|
}
|
|
365
915
|
return this.session;
|
|
366
916
|
}
|
|
917
|
+
/**
|
|
918
|
+
* (Re-)initialises all session-scoped services from the current `this.session`.
|
|
919
|
+
* Called both after the initial `connect()` and after a session refresh.
|
|
920
|
+
*/
|
|
921
|
+
initServices() {
|
|
922
|
+
const authToken = this.session.getAuthToken();
|
|
923
|
+
const sc = this.secureChannel;
|
|
924
|
+
this.attributeService = new AttributeService(authToken, sc);
|
|
925
|
+
this.methodService = new MethodService(authToken, sc);
|
|
926
|
+
this.browseService = new BrowseService(authToken, sc);
|
|
927
|
+
this.subscriptionHandler = new SubscriptionHandler(
|
|
928
|
+
new SubscriptionService(authToken, sc),
|
|
929
|
+
new MonitoredItemService(authToken, sc)
|
|
930
|
+
);
|
|
931
|
+
this.subscriptionHandler.onShutdown = () => this.handleServerShutdownDetected();
|
|
932
|
+
void this.refreshNamespaceTable();
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Reads `Server.NamespaceArray` (ns=0, i=2255) and updates the stored
|
|
936
|
+
* `NamespaceTable`. When the table changes compared to the previous read the
|
|
937
|
+
* `onNamespaceTableChanged` callback is fired so the application can remap
|
|
938
|
+
* any cached NodeIds (OPC UA Part 4, Section 5.7.1 — Session Client Renew
|
|
939
|
+
* NodeIds conformance unit).
|
|
940
|
+
*
|
|
941
|
+
* Errors are logged as warnings and do not propagate to the caller.
|
|
942
|
+
*/
|
|
943
|
+
async refreshNamespaceTable() {
|
|
944
|
+
if (!this.attributeService) return;
|
|
945
|
+
try {
|
|
946
|
+
const results = await this.attributeService.ReadValue([NAMESPACE_ARRAY_NODE_ID]);
|
|
947
|
+
const variant = results[0]?.value;
|
|
948
|
+
const uris = variant?.value;
|
|
949
|
+
if (!Array.isArray(uris)) {
|
|
950
|
+
this.logger.warn("NamespaceArray read returned an unexpected value; skipping table update.");
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
const newTable = new NamespaceTable(uris);
|
|
954
|
+
const oldTable = this.namespaceTable;
|
|
955
|
+
this.namespaceTable = newTable;
|
|
956
|
+
if (oldTable !== void 0 && !oldTable.equals(newTable)) {
|
|
957
|
+
this.logger.info("NamespaceArray changed; notifying application to renew NodeIds.");
|
|
958
|
+
this.onNamespaceTableChanged?.(oldTable, newTable);
|
|
959
|
+
}
|
|
960
|
+
} catch (err) {
|
|
961
|
+
this.logger.warn("NamespaceArray read failed; namespace table not updated:", err);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Returns the most recently read `NamespaceTable` for this session.
|
|
966
|
+
*
|
|
967
|
+
* Available after `connect()` completes (the table is read as part of session
|
|
968
|
+
* establishment). Returns `undefined` before the first successful read.
|
|
969
|
+
*/
|
|
970
|
+
getNamespaceTable() {
|
|
971
|
+
return this.namespaceTable;
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Executes `fn` and, if it throws a `SessionInvalidError`, creates a fresh
|
|
975
|
+
* session and retries the operation exactly once.
|
|
976
|
+
*
|
|
977
|
+
* This covers the reactive case: a service call reveals that the server has
|
|
978
|
+
* already dropped the session (e.g. due to timeout). The new session is
|
|
979
|
+
* established transparently before re-running the original operation.
|
|
980
|
+
*
|
|
981
|
+
* For any other error (e.g. transport-level failures when the SecureChannel
|
|
982
|
+
* drops), this method attempts to reconnect the channel and reactivate the
|
|
983
|
+
* existing session first — falling back to a brand-new session only when
|
|
984
|
+
* reactivation fails — before retrying the operation once.
|
|
985
|
+
*/
|
|
986
|
+
async withSessionRefresh(fn) {
|
|
987
|
+
try {
|
|
988
|
+
return await fn();
|
|
989
|
+
} catch (err) {
|
|
990
|
+
if (err instanceof SessionInvalidError) {
|
|
991
|
+
this.logger.info(`Session invalid (${err.statusCode.toString(16)}), refreshing session...`);
|
|
992
|
+
this.session = await this.sessionHandler.createNewSession(this.identity);
|
|
993
|
+
this.initServices();
|
|
994
|
+
this.logger.info("Session refreshed, retrying operation.");
|
|
995
|
+
return await fn();
|
|
996
|
+
}
|
|
997
|
+
this.logger.info("Service call failed, attempting channel reconnect and session reactivation...");
|
|
998
|
+
try {
|
|
999
|
+
await this.reconnectAndReactivate();
|
|
1000
|
+
this.initServices();
|
|
1001
|
+
this.logger.info("Reconnected successfully, retrying operation.");
|
|
1002
|
+
} catch (reconnectErr) {
|
|
1003
|
+
this.logger.warn("Channel reconnect failed:", reconnectErr);
|
|
1004
|
+
throw err;
|
|
1005
|
+
}
|
|
1006
|
+
return await fn();
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Starts a periodic keep-alive timer that reads Server_ServerStatus when no subscription is
|
|
1011
|
+
* active. OPC UA Part 4, Section 5.7.1 requires clients to keep the session alive; when no
|
|
1012
|
+
* subscription Publish loop is running this is the only mechanism that does so.
|
|
1013
|
+
*
|
|
1014
|
+
* The keep-alive read also serves as the **Detect Shutdown** mechanism (Session Client Detect
|
|
1015
|
+
* Shutdown conformance unit): when the returned `ServerStatusDataType.state` equals
|
|
1016
|
+
* `ServerStateEnum.Shutdown` the client schedules a reconnect after
|
|
1017
|
+
* `SHUTDOWN_RECONNECT_DELAY_MS` to let the server finish its shutdown sequence.
|
|
1018
|
+
*/
|
|
1019
|
+
startKeepAlive() {
|
|
1020
|
+
this.keepAliveTimer = setInterval(() => {
|
|
1021
|
+
if (this.subscriptionHandler?.hasActiveSubscription()) {
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
if (this.attributeService) {
|
|
1025
|
+
void this.attributeService.ReadValue([SERVER_STATUS_NODE_ID]).then((results) => {
|
|
1026
|
+
const statusData = results[0]?.value;
|
|
1027
|
+
if (statusData?.state === ServerStateEnum.Shutdown) {
|
|
1028
|
+
this.handleServerShutdownDetected();
|
|
1029
|
+
}
|
|
1030
|
+
}).catch((err) => {
|
|
1031
|
+
this.logger.warn("Keep-alive read failed:", err);
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
}, KEEP_ALIVE_INTERVAL_MS);
|
|
1035
|
+
}
|
|
1036
|
+
stopKeepAlive() {
|
|
1037
|
+
clearInterval(this.keepAliveTimer);
|
|
1038
|
+
this.keepAliveTimer = void 0;
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Called when a server-shutdown announcement is detected — either via the keep-alive read
|
|
1042
|
+
* returning `ServerStateEnum.Shutdown` or via a subscription `StatusChangeNotification`
|
|
1043
|
+
* with status `BadShutdown` / `BadServerHalted`.
|
|
1044
|
+
*
|
|
1045
|
+
* Reads `Server/ServerStatus/EstimatedReturnTime` (ns=0, i=2992) to decide how long to
|
|
1046
|
+
* wait before reconnecting (Base Info Client Estimated Return Time conformance unit).
|
|
1047
|
+
* Falls back to `configuration.shutdownReconnectDelayMs` when the read fails or the
|
|
1048
|
+
* attributed service is not yet available. Fires `onPermanentShutdown` and suppresses the
|
|
1049
|
+
* reconnect when the server sends `MinDateTime`.
|
|
1050
|
+
*
|
|
1051
|
+
* Only one reconnect attempt is scheduled at a time; a second detection while one is already
|
|
1052
|
+
* pending is silently ignored.
|
|
1053
|
+
*/
|
|
1054
|
+
handleServerShutdownDetected() {
|
|
1055
|
+
if (this.shutdownReconnectPending) {
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
this.shutdownReconnectPending = true;
|
|
1059
|
+
this.stopKeepAlive();
|
|
1060
|
+
this.logger.warn("Server shutdown detected; reading EstimatedReturnTime...");
|
|
1061
|
+
void this.computeReconnectDelayMs().then((delayMs) => {
|
|
1062
|
+
if (delayMs === null) {
|
|
1063
|
+
this.logger.warn(
|
|
1064
|
+
"Server indicated it will not restart (MinDateTime). Firing onPermanentShutdown."
|
|
1065
|
+
);
|
|
1066
|
+
this.onPermanentShutdown?.();
|
|
1067
|
+
this.shutdownReconnectPending = false;
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
this.logger.warn(`Scheduling reconnect in ${delayMs} ms...`);
|
|
1071
|
+
setTimeout(() => {
|
|
1072
|
+
void this.reconnectAndReactivate().then(() => {
|
|
1073
|
+
this.initServices();
|
|
1074
|
+
this.startKeepAlive();
|
|
1075
|
+
this.logger.info("Reconnected after server shutdown.");
|
|
1076
|
+
}).catch((err) => {
|
|
1077
|
+
this.logger.warn("Reconnect after server shutdown failed:", err);
|
|
1078
|
+
}).finally(() => {
|
|
1079
|
+
this.shutdownReconnectPending = false;
|
|
1080
|
+
});
|
|
1081
|
+
}, delayMs);
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Reads `Server/ServerStatus/EstimatedReturnTime` (ns=0, i=2992) and returns the reconnect
|
|
1086
|
+
* delay in milliseconds (Base Info Client Estimated Return Time — OPC UA Part 5, §12.6):
|
|
1087
|
+
*
|
|
1088
|
+
* - Valid future `DateTime` → delay = `estimatedReturnTime − now`, clamped to at least
|
|
1089
|
+
* `MIN_RECONNECT_DELAY_MS`.
|
|
1090
|
+
* - Past `DateTime` (server should already be available) → `MIN_RECONNECT_DELAY_MS`.
|
|
1091
|
+
* - OPC UA `MinDateTime` (server will not restart) → `null`.
|
|
1092
|
+
* - Unreadable / unavailable → falls back to `configuration.shutdownReconnectDelayMs`.
|
|
1093
|
+
*/
|
|
1094
|
+
async computeReconnectDelayMs() {
|
|
1095
|
+
if (!this.attributeService) {
|
|
1096
|
+
return this.configuration.shutdownReconnectDelayMs;
|
|
1097
|
+
}
|
|
1098
|
+
try {
|
|
1099
|
+
const results = await this.attributeService.ReadValue([ESTIMATED_RETURN_TIME_NODE_ID]);
|
|
1100
|
+
const variant = results[0]?.value;
|
|
1101
|
+
const estimatedReturnTime = variant?.value;
|
|
1102
|
+
if (estimatedReturnTime instanceof Date && !isNaN(estimatedReturnTime.getTime())) {
|
|
1103
|
+
if (estimatedReturnTime.getTime() <= OPC_UA_MIN_DATE_TIME_MS) {
|
|
1104
|
+
return null;
|
|
1105
|
+
}
|
|
1106
|
+
const delayMs = estimatedReturnTime.getTime() - Date.now();
|
|
1107
|
+
if (delayMs > 0) {
|
|
1108
|
+
this.logger.info(
|
|
1109
|
+
`EstimatedReturnTime: ${estimatedReturnTime.toISOString()} (reconnect in ${delayMs} ms).`
|
|
1110
|
+
);
|
|
1111
|
+
return delayMs;
|
|
1112
|
+
}
|
|
1113
|
+
this.logger.info("EstimatedReturnTime is in the past; reconnecting immediately.");
|
|
1114
|
+
return this.configuration.minReconnectDelayMs;
|
|
1115
|
+
}
|
|
1116
|
+
} catch (err) {
|
|
1117
|
+
this.logger.debug("Failed to read EstimatedReturnTime; using configured delay:", err);
|
|
1118
|
+
}
|
|
1119
|
+
return this.configuration.shutdownReconnectDelayMs;
|
|
1120
|
+
}
|
|
367
1121
|
async connect() {
|
|
368
|
-
const
|
|
1122
|
+
const { ws, sc } = await this.openTransportAndChannel();
|
|
1123
|
+
this.secureChannel = sc;
|
|
1124
|
+
this.secureChannelFacade = sc;
|
|
1125
|
+
this.ws = ws;
|
|
1126
|
+
this.logger.debug("Creating session...");
|
|
1127
|
+
this.sessionHandler = new SessionHandler(sc, this.configuration);
|
|
1128
|
+
this.session = await this.sessionHandler.createNewSession(this.identity);
|
|
1129
|
+
this.logger.debug("Session created.");
|
|
1130
|
+
this.logger.debug("Initializing services...");
|
|
1131
|
+
this.initServices();
|
|
1132
|
+
this.startKeepAlive();
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Builds the full WebSocket → TCP → SecureChannel pipeline and returns the
|
|
1136
|
+
* two objects needed to drive it: the raw WebSocket facade (for teardown)
|
|
1137
|
+
* and the SecureChannelFacade (for service requests/session management).
|
|
1138
|
+
*
|
|
1139
|
+
* Extracted from `connect()` so it can be reused by `reconnectAndReactivate()`.
|
|
1140
|
+
*/
|
|
1141
|
+
async openTransportAndChannel() {
|
|
1142
|
+
const wsOptions = { endpoint: this.endpointUrl };
|
|
1143
|
+
const ws = new WebSocketFascade(wsOptions);
|
|
1144
|
+
const webSocketReadableStream = new WebSocketReadableStream(ws, 1e3);
|
|
1145
|
+
const webSocketWritableStream = new WebSocketWritableStream(ws);
|
|
1146
|
+
const scContext = new SecureChannelContext(this.endpointUrl);
|
|
1147
|
+
const tcpMessageInjector = new TcpMessageInjector();
|
|
1148
|
+
const tcpConnectionHandler = new TcpConnectionHandler(tcpMessageInjector, scContext);
|
|
1149
|
+
const tcpMessageDecoupler = new TcpMessageDecoupler(tcpConnectionHandler.onTcpMessage.bind(tcpConnectionHandler));
|
|
1150
|
+
const scMessageEncoder = new SecureChannelMessageEncoder(scContext);
|
|
1151
|
+
const scTypeDecoder = new SecureChannelTypeDecoder(
|
|
1152
|
+
this.configuration.decoder
|
|
1153
|
+
);
|
|
1154
|
+
const scMessageDecoder = new SecureChannelMessageDecoder(scContext);
|
|
1155
|
+
const scTypeEncoder = new SecureChannelTypeEncoder(
|
|
1156
|
+
this.configuration.encoder
|
|
1157
|
+
);
|
|
1158
|
+
const scChunkWriter = new SecureChannelChunkWriter(scContext);
|
|
1159
|
+
const scChunkReader = new SecureChannelChunkReader(scContext);
|
|
1160
|
+
webSocketReadableStream.pipeTo(tcpMessageDecoupler.writable);
|
|
1161
|
+
tcpMessageDecoupler.readable.pipeTo(scMessageDecoder.writable);
|
|
1162
|
+
scMessageDecoder.readable.pipeTo(scChunkReader.writable);
|
|
1163
|
+
scChunkReader.readable.pipeTo(scTypeDecoder.writable);
|
|
1164
|
+
scTypeEncoder.readable.pipeTo(scChunkWriter.writable);
|
|
1165
|
+
scChunkWriter.readable.pipeTo(scMessageEncoder.writable);
|
|
1166
|
+
scMessageEncoder.readable.pipeTo(tcpMessageInjector.writable);
|
|
1167
|
+
tcpMessageInjector.readable.pipeTo(webSocketWritableStream);
|
|
1168
|
+
const sc = new SecureChannelFacade(scContext, scTypeDecoder, scTypeEncoder);
|
|
369
1169
|
let connected = false;
|
|
370
1170
|
while (!connected) {
|
|
371
|
-
|
|
372
|
-
|
|
1171
|
+
this.logger.debug(`Connecting to OPC UA server at ${this.endpointUrl}...`);
|
|
1172
|
+
await ws.connect();
|
|
1173
|
+
this.logger.debug("WebSocket connection established, now establishing TCP connection...");
|
|
1174
|
+
connected = await tcpConnectionHandler.connect(this.endpointUrl);
|
|
373
1175
|
if (!connected) {
|
|
374
|
-
|
|
1176
|
+
this.logger.info("Connection failed, retrying in 2 seconds...");
|
|
375
1177
|
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
376
1178
|
}
|
|
377
1179
|
}
|
|
378
|
-
|
|
379
|
-
this.
|
|
380
|
-
await
|
|
381
|
-
|
|
382
|
-
this.
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
1180
|
+
this.logger.info("Connected to OPC UA server.");
|
|
1181
|
+
this.logger.debug("Opening secure channel...");
|
|
1182
|
+
await sc.openSecureChannel();
|
|
1183
|
+
this.logger.debug("Secure channel established.");
|
|
1184
|
+
this.enforceChannelSecurityConfig(sc);
|
|
1185
|
+
return { ws, sc };
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Validates the negotiated channel's security policy and mode against the
|
|
1189
|
+
* client's `SecurityConfiguration` (OPC UA Part 2, Security Administration).
|
|
1190
|
+
*
|
|
1191
|
+
* Throws if:
|
|
1192
|
+
* - `allowSecurityPolicyNone` is `false` and the channel uses SecurityPolicy None.
|
|
1193
|
+
* - `messageSecurityMode` is set and does not match the channel's actual mode.
|
|
1194
|
+
*/
|
|
1195
|
+
enforceChannelSecurityConfig(sc) {
|
|
1196
|
+
const config = this.configuration.securityConfiguration;
|
|
1197
|
+
if (!config) return;
|
|
1198
|
+
const negotiatedPolicy = sc.getSecurityPolicy();
|
|
1199
|
+
const negotiatedMode = sc.getSecurityMode();
|
|
1200
|
+
if (config.allowSecurityPolicyNone === false && negotiatedPolicy === SECURITY_POLICY_NONE_URI) {
|
|
1201
|
+
throw new Error(
|
|
1202
|
+
"Connection refused: SecurityPolicy None is disabled by the client security configuration. Only SecurityPolicy None is currently supported by this client implementation."
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
if (config.messageSecurityMode !== void 0 && config.messageSecurityMode !== negotiatedMode) {
|
|
1206
|
+
throw new Error(
|
|
1207
|
+
`Connection refused: negotiated MessageSecurityMode ${negotiatedMode} does not match the required mode ${config.messageSecurityMode} from the security configuration.`
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
387
1210
|
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Tears down the current (dead) channel and establishes a fresh one, then
|
|
1213
|
+
* attempts to recover the existing OPC UA session via ActivateSession before
|
|
1214
|
+
* falling back to a full CreateSession + ActivateSession.
|
|
1215
|
+
*
|
|
1216
|
+
* OPC UA Part 4, Section 5.7.1 / Session Client Auto Reconnect conformance unit:
|
|
1217
|
+
* When the SecureChannel drops but the server-side session has not yet timed
|
|
1218
|
+
* out, the client SHOULD reuse the existing session by calling ActivateSession
|
|
1219
|
+
* on the new channel. Only if that fails should the client create a new session.
|
|
1220
|
+
*/
|
|
1221
|
+
async reconnectAndReactivate() {
|
|
1222
|
+
this.logger.info("Tearing down dead channel before reconnect...");
|
|
1223
|
+
try {
|
|
1224
|
+
this.secureChannelFacade?.close();
|
|
1225
|
+
} catch {
|
|
1226
|
+
}
|
|
1227
|
+
try {
|
|
1228
|
+
this.ws?.close();
|
|
1229
|
+
} catch {
|
|
1230
|
+
}
|
|
1231
|
+
this.secureChannelFacade = void 0;
|
|
1232
|
+
this.secureChannel = void 0;
|
|
1233
|
+
this.ws = void 0;
|
|
1234
|
+
const { ws, sc } = await this.openTransportAndChannel();
|
|
1235
|
+
this.secureChannel = sc;
|
|
1236
|
+
this.secureChannelFacade = sc;
|
|
1237
|
+
this.ws = ws;
|
|
1238
|
+
this.sessionHandler = new SessionHandler(sc, this.configuration);
|
|
1239
|
+
if (this.session) {
|
|
1240
|
+
const reactivated = await this.sessionHandler.tryActivateExistingSession(
|
|
1241
|
+
this.session.getAuthToken(),
|
|
1242
|
+
this.session.getSessionId(),
|
|
1243
|
+
this.session.getEndpoint(),
|
|
1244
|
+
this.identity
|
|
1245
|
+
);
|
|
1246
|
+
if (reactivated !== null) {
|
|
1247
|
+
this.session = reactivated;
|
|
1248
|
+
this.logger.info("Existing session successfully reactivated on new channel.");
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
this.logger.info("ActivateSession for existing session failed; creating a fresh session...");
|
|
1252
|
+
}
|
|
1253
|
+
this.session = await this.sessionHandler.createNewSession(this.identity);
|
|
1254
|
+
this.logger.info("Fresh session established on new channel.");
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Gracefully disconnects from the OPC UA server.
|
|
1258
|
+
*
|
|
1259
|
+
* Sequence per OPC UA Part 4, Section 5.7.4:
|
|
1260
|
+
* 1. CloseSession (deleteSubscriptions=true) so the server frees all resources.
|
|
1261
|
+
* 2. Close the SecureChannel, which cancels the pending token-renewal timer.
|
|
1262
|
+
* 3. Close the WebSocket transport.
|
|
1263
|
+
*
|
|
1264
|
+
* CloseSession errors are swallowed so transport teardown always completes even
|
|
1265
|
+
* when the session has already expired on the server side.
|
|
1266
|
+
*/
|
|
388
1267
|
async disconnect() {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
1268
|
+
this.logger.info("Disconnecting from OPC UA server...");
|
|
1269
|
+
this.stopKeepAlive();
|
|
1270
|
+
if (this.session && this.sessionHandler) {
|
|
1271
|
+
try {
|
|
1272
|
+
await this.sessionHandler.closeSession(true);
|
|
1273
|
+
} catch (err) {
|
|
1274
|
+
this.logger.warn("CloseSession failed (continuing teardown):", err);
|
|
1275
|
+
}
|
|
1276
|
+
this.session = void 0;
|
|
1277
|
+
}
|
|
1278
|
+
this.secureChannelFacade?.close();
|
|
1279
|
+
this.secureChannelFacade = void 0;
|
|
1280
|
+
this.secureChannel = void 0;
|
|
1281
|
+
this.ws?.close();
|
|
1282
|
+
this.ws = void 0;
|
|
1283
|
+
this.logger.info("Disconnected.");
|
|
1284
|
+
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Reads the Value attribute of one or more Nodes.
|
|
1287
|
+
*
|
|
1288
|
+
* The returned object is a `Promise` that also exposes `requestHandle` — the
|
|
1289
|
+
* OPC UA `requestHandle` assigned to the underlying `ReadRequest`. The handle
|
|
1290
|
+
* is available synchronously (before `await`) so it can be passed to
|
|
1291
|
+
* `cancel()` to abort the in-flight request.
|
|
1292
|
+
*
|
|
1293
|
+
* @example
|
|
1294
|
+
* ```ts
|
|
1295
|
+
* const req = client.read([nodeId])
|
|
1296
|
+
* await client.cancel(req.requestHandle) // abort before response
|
|
1297
|
+
* const results = await req // ReadValueResult[]
|
|
1298
|
+
* ```
|
|
1299
|
+
*/
|
|
1300
|
+
read(ids, options) {
|
|
1301
|
+
const requestHandle = nextRequestHandle();
|
|
1302
|
+
const promise = this.withSessionRefresh(async () => {
|
|
1303
|
+
const result = await this.attributeService?.ReadValue(ids, 0, void 0, options?.returnDiagnostics, requestHandle);
|
|
1304
|
+
return result?.map((r) => new ReadValueResult(r.value, r.statusCode, r.diagnosticInfo)) ?? [];
|
|
1305
|
+
});
|
|
1306
|
+
return Object.assign(promise, { requestHandle });
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Method for calling a single method on the server.
|
|
1310
|
+
*
|
|
1311
|
+
* The returned object is a `Promise` that also exposes `requestHandle` — the
|
|
1312
|
+
* OPC UA `requestHandle` assigned to the underlying `CallRequest`. The handle
|
|
1313
|
+
* is available synchronously (before `await`) so it can be passed to
|
|
1314
|
+
* `cancel()` to abort the in-flight request.
|
|
1315
|
+
*
|
|
1316
|
+
* @param objectId - NodeId of the Object that owns the method.
|
|
1317
|
+
* @param methodId - NodeId of the Method to invoke.
|
|
1318
|
+
* @param inputArguments - Input argument Variants (default: empty).
|
|
1319
|
+
* @param options - Request options (e.g. `returnDiagnostics`).
|
|
1320
|
+
* @returns A promise resolving to the CallMethodResult, with `requestHandle` available synchronously.
|
|
1321
|
+
*/
|
|
1322
|
+
callMethod(objectId, methodId, inputArguments = [], options) {
|
|
1323
|
+
const requestHandle = nextRequestHandle();
|
|
1324
|
+
const promise = this.withSessionRefresh(async () => {
|
|
1325
|
+
const request = new CallMethodRequest();
|
|
1326
|
+
request.objectId = objectId;
|
|
1327
|
+
request.methodId = methodId;
|
|
1328
|
+
request.inputArguments = inputArguments.map((arg) => Variant.newFrom(arg));
|
|
1329
|
+
const responses = await this.methodService.call([request], options?.returnDiagnostics, requestHandle);
|
|
1330
|
+
const response = responses[0];
|
|
1331
|
+
return new CallMethodResult(response.value, response.statusCode, response.diagnosticInfo);
|
|
1332
|
+
});
|
|
1333
|
+
return Object.assign(promise, { requestHandle });
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Browses the Address Space starting from `nodeId`.
|
|
1337
|
+
*
|
|
1338
|
+
* The returned object is a `Promise` that also exposes `requestHandle` — the
|
|
1339
|
+
* OPC UA `requestHandle` assigned to the initial `BrowseRequest`. The handle
|
|
1340
|
+
* is available synchronously (before `await`) so it can be passed to
|
|
1341
|
+
* `cancel()` to abort the in-flight request.
|
|
1342
|
+
*
|
|
1343
|
+
* @param nodeId - Starting node.
|
|
1344
|
+
* @param recursive - When true, recursively follows HierarchicalReferences.
|
|
1345
|
+
* @param options - Request options (e.g. `returnDiagnostics`).
|
|
1346
|
+
* @returns A promise resolving to the list of referenced nodes, with `requestHandle` available synchronously.
|
|
1347
|
+
*/
|
|
1348
|
+
browse(nodeId, recursive = false, options) {
|
|
1349
|
+
const requestHandle = nextRequestHandle();
|
|
1350
|
+
const promise = this.withSessionRefresh(() => {
|
|
1351
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1352
|
+
return this.browseRecursive(nodeId, recursive, visited, options?.returnDiagnostics ?? 0, requestHandle);
|
|
1353
|
+
});
|
|
1354
|
+
return Object.assign(promise, { requestHandle });
|
|
1355
|
+
}
|
|
1356
|
+
async browseRecursive(nodeId, recursive, visited, returnDiagnostics, requestHandle) {
|
|
1357
|
+
const nodeKey = `${nodeId.namespace}:${nodeId.identifier}`;
|
|
1358
|
+
if (visited.has(nodeKey)) {
|
|
1359
|
+
return [];
|
|
1360
|
+
}
|
|
1361
|
+
visited.add(nodeKey);
|
|
1362
|
+
const description = new BrowseDescription();
|
|
1363
|
+
description.nodeId = nodeId;
|
|
1364
|
+
description.browseDirection = BrowseDirectionEnum.Forward;
|
|
1365
|
+
description.referenceTypeId = NodeId.newNumeric(0, 33);
|
|
1366
|
+
description.includeSubtypes = true;
|
|
1367
|
+
description.nodeClassMask = 0;
|
|
1368
|
+
description.resultMask = BrowseResultMaskEnum.All;
|
|
1369
|
+
const browseResults = await this.browseService.browse([description], returnDiagnostics, requestHandle);
|
|
1370
|
+
const browseResult = browseResults[0];
|
|
1371
|
+
const allReferences = [...browseResult.references ?? []];
|
|
1372
|
+
let continuationPoint = browseResult.continuationPoint;
|
|
1373
|
+
while (continuationPoint && continuationPoint.byteLength > 0) {
|
|
1374
|
+
const nextResults = await this.browseService.browseNext([continuationPoint], false, returnDiagnostics);
|
|
1375
|
+
const nextResult = nextResults[0];
|
|
1376
|
+
allReferences.push(...nextResult.references ?? []);
|
|
1377
|
+
continuationPoint = nextResult.continuationPoint;
|
|
1378
|
+
}
|
|
1379
|
+
const results = allReferences.map(
|
|
1380
|
+
(ref) => new BrowseNodeResult(
|
|
1381
|
+
ref.referenceTypeId,
|
|
1382
|
+
ref.isForward,
|
|
1383
|
+
ref.nodeId,
|
|
1384
|
+
ref.browseName,
|
|
1385
|
+
ref.displayName,
|
|
1386
|
+
ref.nodeClass,
|
|
1387
|
+
ref.typeDefinition
|
|
1388
|
+
)
|
|
1389
|
+
);
|
|
1390
|
+
if (recursive) {
|
|
1391
|
+
for (const ref of allReferences) {
|
|
1392
|
+
const childNodeId = NodeId.newNumeric(
|
|
1393
|
+
ref.nodeId.nodeId.namespace,
|
|
1394
|
+
ref.nodeId.nodeId.identifier
|
|
1395
|
+
);
|
|
1396
|
+
const childResults = await this.browseRecursive(
|
|
1397
|
+
childNodeId,
|
|
1398
|
+
true,
|
|
1399
|
+
visited,
|
|
1400
|
+
returnDiagnostics
|
|
1401
|
+
);
|
|
1402
|
+
results.push(...childResults);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
return results;
|
|
1406
|
+
}
|
|
1407
|
+
async subscribe(ids, callback, options) {
|
|
1408
|
+
this.subscriptionHandler?.subscribe(ids, callback, options);
|
|
1409
|
+
}
|
|
1410
|
+
/**
|
|
1411
|
+
* Asks the server to cancel a pending service request
|
|
1412
|
+
* (OPC UA Part 4, Section 5.7.5 — Session Client Cancel conformance unit).
|
|
1413
|
+
*
|
|
1414
|
+
* The `requestHandle` uniquely identifies the pending request. It is the value
|
|
1415
|
+
* assigned to `RequestHeader.requestHandle` when the request was initially sent.
|
|
1416
|
+
* Service calls made through this client automatically assign monotonically
|
|
1417
|
+
* increasing handles, so the caller can capture the handle before or after issuing
|
|
1418
|
+
* Each method (`read`, `browse`, `callMethod`) returns a `Promise` with a
|
|
1419
|
+
* `requestHandle` property that is available synchronously. Pass that handle
|
|
1420
|
+
* here to abort the corresponding in-flight request.
|
|
1421
|
+
*
|
|
1422
|
+
* The server makes a best-effort attempt to cancel the matching request. Cancelled
|
|
1423
|
+
* requests complete with status `BadRequestCancelledByClient`. Not all servers
|
|
1424
|
+
* guarantee that a request in flight can be cancelled.
|
|
1425
|
+
*
|
|
1426
|
+
* @param requestHandle - Handle of the pending request to cancel.
|
|
1427
|
+
* @returns The number of pending requests the server actually cancelled.
|
|
1428
|
+
* @throws If no session is active or the server returns a non-Good status.
|
|
1429
|
+
*
|
|
1430
|
+
* @example
|
|
1431
|
+
* ```ts
|
|
1432
|
+
* // Issue a potentially slow operation and immediately cancel it.
|
|
1433
|
+
* const req = client.read([nodeId])
|
|
1434
|
+
* const cancelled = await client.cancel(req.requestHandle)
|
|
1435
|
+
* console.log(`Cancelled ${cancelled} request(s)`)
|
|
1436
|
+
* const results = await req // resolves with BadRequestCancelledByClient
|
|
1437
|
+
* ```
|
|
1438
|
+
*/
|
|
1439
|
+
async cancel(requestHandle) {
|
|
1440
|
+
if (!this.sessionHandler) {
|
|
1441
|
+
throw new Error("Not connected: call connect() before cancel()");
|
|
392
1442
|
}
|
|
1443
|
+
return this.sessionHandler.cancel(requestHandle);
|
|
393
1444
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
1445
|
+
/**
|
|
1446
|
+
* Switches the active user identity for the current session by calling ActivateSession
|
|
1447
|
+
* with a new identity token (OPC UA Part 4, Section 5.7.3 — Session Client Impersonate
|
|
1448
|
+
* conformance unit).
|
|
1449
|
+
*
|
|
1450
|
+
* The server re-evaluates authorisation under the new identity while keeping all existing
|
|
1451
|
+
* Subscriptions and MonitoredItems intact. The new identity is also stored so that any
|
|
1452
|
+
* subsequent auto-reconnect or session refresh uses it instead of the original identity.
|
|
1453
|
+
*
|
|
1454
|
+
* @param identity - The new user identity to apply to the session.
|
|
1455
|
+
* @throws {Error} When not connected (call `connect()` first).
|
|
1456
|
+
* @throws {Error} When the server rejects the identity (e.g. `BadIdentityTokenRejected`
|
|
1457
|
+
* or `BadUserAccessDenied`).
|
|
1458
|
+
*
|
|
1459
|
+
* @example
|
|
1460
|
+
* ```ts
|
|
1461
|
+
* await client.connect()
|
|
1462
|
+
* // ... work as the original user ...
|
|
1463
|
+
* await client.impersonate(UserIdentity.newWithUserName('admin', 'secret'))
|
|
1464
|
+
* // ... subsequent calls run under the admin identity ...
|
|
1465
|
+
* ```
|
|
1466
|
+
*/
|
|
1467
|
+
async impersonate(identity) {
|
|
1468
|
+
if (!this.session) {
|
|
1469
|
+
throw new Error("Not connected: call connect() before impersonate()");
|
|
1470
|
+
}
|
|
1471
|
+
await this.session.impersonate(identity);
|
|
1472
|
+
this.identity = identity;
|
|
398
1473
|
}
|
|
399
|
-
|
|
400
|
-
|
|
1474
|
+
/**
|
|
1475
|
+
* Reads the `SelectionListType` metadata for a Variable
|
|
1476
|
+
* (OPC UA Part 5, §7.18 — Base Info Client Selection List conformance unit).
|
|
1477
|
+
*
|
|
1478
|
+
* The client browses the node's `HasProperty` references for `Selections`,
|
|
1479
|
+
* `SelectionDescriptions`, and `RestrictToList`, then reads their values in a
|
|
1480
|
+
* single batch Read request.
|
|
1481
|
+
*
|
|
1482
|
+
* Works transparently for instances of `SelectionListType` (ns=0; i=19726) and
|
|
1483
|
+
* any of its subtypes, because all subtypes inherit the `Selections` mandatory
|
|
1484
|
+
* property.
|
|
1485
|
+
*
|
|
1486
|
+
* @param nodeId - NodeId of the Variable to inspect.
|
|
1487
|
+
* @returns `SelectionList` when the node has a `Selections` property, `null` otherwise.
|
|
1488
|
+
* @throws When not connected or if the server returns a non-Good service status.
|
|
1489
|
+
*
|
|
1490
|
+
* @example
|
|
1491
|
+
* ```ts
|
|
1492
|
+
* const list = await client.getSelectionList(nodeId)
|
|
1493
|
+
* if (list) {
|
|
1494
|
+
* list.selectionDescriptions.forEach((desc, i) =>
|
|
1495
|
+
* console.log(`[${i}] ${desc.text}:`, list.selections[i])
|
|
1496
|
+
* )
|
|
1497
|
+
* }
|
|
1498
|
+
* ```
|
|
1499
|
+
*/
|
|
1500
|
+
getSelectionList(nodeId) {
|
|
1501
|
+
return this.withSessionRefresh(() => this.fetchSelectionList(nodeId));
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* Internal implementation of `getSelectionList`. Browses the node's
|
|
1505
|
+
* HasProperty references to locate Selections/SelectionDescriptions/RestrictToList,
|
|
1506
|
+
* then batch-reads their values.
|
|
1507
|
+
*/
|
|
1508
|
+
async fetchSelectionList(nodeId) {
|
|
1509
|
+
if (!this.browseService || !this.attributeService) {
|
|
1510
|
+
throw new Error("Not connected: call connect() before getSelectionList()");
|
|
1511
|
+
}
|
|
1512
|
+
const description = new BrowseDescription();
|
|
1513
|
+
description.nodeId = nodeId;
|
|
1514
|
+
description.browseDirection = BrowseDirectionEnum.Forward;
|
|
1515
|
+
description.referenceTypeId = HAS_PROPERTY_REF_TYPE_ID;
|
|
1516
|
+
description.includeSubtypes = false;
|
|
1517
|
+
description.nodeClassMask = 0;
|
|
1518
|
+
description.resultMask = BrowseResultMaskEnum.All;
|
|
1519
|
+
const browseResults = await this.browseService.browse([description]);
|
|
1520
|
+
const refs = browseResults[0]?.references ?? [];
|
|
1521
|
+
let selectionsNodeId;
|
|
1522
|
+
let selectionsDescNodeId;
|
|
1523
|
+
let restrictToListNodeId;
|
|
1524
|
+
for (const ref of refs) {
|
|
1525
|
+
const name = ref.browseName?.name;
|
|
1526
|
+
if (name === "Selections") {
|
|
1527
|
+
selectionsNodeId = ref.nodeId.nodeId;
|
|
1528
|
+
} else if (name === "SelectionDescriptions") {
|
|
1529
|
+
selectionsDescNodeId = ref.nodeId.nodeId;
|
|
1530
|
+
} else if (name === "RestrictToList") {
|
|
1531
|
+
restrictToListNodeId = ref.nodeId.nodeId;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
if (!selectionsNodeId) {
|
|
1535
|
+
return null;
|
|
1536
|
+
}
|
|
1537
|
+
const nodeIdsToRead = [selectionsNodeId];
|
|
1538
|
+
if (selectionsDescNodeId) nodeIdsToRead.push(selectionsDescNodeId);
|
|
1539
|
+
if (restrictToListNodeId) nodeIdsToRead.push(restrictToListNodeId);
|
|
1540
|
+
const readResults = await this.attributeService.ReadValue(nodeIdsToRead);
|
|
1541
|
+
const selectionsVariant = readResults[0]?.value;
|
|
1542
|
+
const selections = Array.isArray(selectionsVariant?.value) ? selectionsVariant.value : [];
|
|
1543
|
+
let selectionDescriptions = [];
|
|
1544
|
+
let descReadIdx = 1;
|
|
1545
|
+
if (selectionsDescNodeId) {
|
|
1546
|
+
const descVariant = readResults[descReadIdx]?.value;
|
|
1547
|
+
if (Array.isArray(descVariant?.value)) {
|
|
1548
|
+
selectionDescriptions = descVariant.value;
|
|
1549
|
+
}
|
|
1550
|
+
descReadIdx++;
|
|
1551
|
+
}
|
|
1552
|
+
let restrictToList = false;
|
|
1553
|
+
if (restrictToListNodeId) {
|
|
1554
|
+
const rtlVariant = readResults[descReadIdx]?.value;
|
|
1555
|
+
if (typeof rtlVariant?.value === "boolean") {
|
|
1556
|
+
restrictToList = rtlVariant.value;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
return { nodeId, selections, selectionDescriptions, restrictToList };
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* The `requestHandle` value that was assigned to the most recently issued
|
|
1563
|
+
* service request in this session.
|
|
1564
|
+
*
|
|
1565
|
+
* @deprecated Prefer accessing `requestHandle` directly on the promise returned
|
|
1566
|
+
* by `read()`, `browse()`, or `callMethod()`, which is available synchronously
|
|
1567
|
+
* before `await` and avoids relying on shared module state.
|
|
1568
|
+
*
|
|
1569
|
+
* Returns `0` before any request has been sent.
|
|
1570
|
+
*/
|
|
1571
|
+
get lastRequestHandle() {
|
|
1572
|
+
return lastAssignedHandle();
|
|
401
1573
|
}
|
|
402
1574
|
};
|
|
403
1575
|
var ConfigurationClient = class _ConfigurationClient extends Configuration {
|
|
404
|
-
|
|
1576
|
+
/**
|
|
1577
|
+
* Optional security restrictions applied during `Client.connect()`.
|
|
1578
|
+
* When not set, permissive defaults are used (SecurityPolicy None allowed,
|
|
1579
|
+
* all user-token types accepted).
|
|
1580
|
+
*
|
|
1581
|
+
* @see SecurityConfiguration
|
|
1582
|
+
*/
|
|
1583
|
+
securityConfiguration;
|
|
1584
|
+
/**
|
|
1585
|
+
* How long to wait (ms) before attempting a reconnect after a server-shutdown
|
|
1586
|
+
* is detected via `ServerStatus/State = Shutdown` or a subscription
|
|
1587
|
+
* `StatusChangeNotification` with `BadShutdown` / `BadServerHalted`.
|
|
1588
|
+
*
|
|
1589
|
+
* Gives the server process time to exit fully before the client tries to
|
|
1590
|
+
* re-connect. Defaults to 5 000 ms.
|
|
1591
|
+
*/
|
|
1592
|
+
shutdownReconnectDelayMs = 5e3;
|
|
1593
|
+
/**
|
|
1594
|
+
* Minimum reconnect delay in milliseconds used when
|
|
1595
|
+
* `Server/ServerStatus/EstimatedReturnTime` is already in the past (the server
|
|
1596
|
+
* should already be available again).
|
|
1597
|
+
*
|
|
1598
|
+
* Also acts as the lower bound for the ERT-derived delay, ensuring the client
|
|
1599
|
+
* always waits at least this long before retrying.
|
|
1600
|
+
*
|
|
1601
|
+
* Defaults to 1 000 ms.
|
|
1602
|
+
*/
|
|
1603
|
+
minReconnectDelayMs = 1e3;
|
|
1604
|
+
static getSimple(name, company, loggerFactory) {
|
|
1605
|
+
if (!loggerFactory) {
|
|
1606
|
+
loggerFactory = new LoggerFactory({
|
|
1607
|
+
defaultLevel: "DEBUG",
|
|
1608
|
+
//todo: use enum
|
|
1609
|
+
categoryLevels: {
|
|
1610
|
+
"transport.*": "TRACE",
|
|
1611
|
+
"secureChannel.*": "TRACE"
|
|
1612
|
+
}
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
405
1615
|
const applicationUri = `urn:${company}:${name}`;
|
|
406
1616
|
const productUri = `urn:${company}:${name}:product`;
|
|
407
1617
|
const encoder = new Encoder();
|
|
@@ -415,10 +1625,26 @@ var ConfigurationClient = class _ConfigurationClient extends Configuration {
|
|
|
415
1625
|
});
|
|
416
1626
|
registerTypeDecoders(decoder);
|
|
417
1627
|
registerBinaryDecoders(decoder);
|
|
418
|
-
return new _ConfigurationClient(
|
|
1628
|
+
return new _ConfigurationClient(
|
|
1629
|
+
name,
|
|
1630
|
+
applicationUri,
|
|
1631
|
+
name,
|
|
1632
|
+
productUri,
|
|
1633
|
+
encoder,
|
|
1634
|
+
decoder,
|
|
1635
|
+
loggerFactory
|
|
1636
|
+
);
|
|
419
1637
|
}
|
|
420
|
-
constructor(applicationName, applicationUri, productName, productUri, encoder, decoder) {
|
|
421
|
-
super(
|
|
1638
|
+
constructor(applicationName, applicationUri, productName, productUri, encoder, decoder, loggerFactory) {
|
|
1639
|
+
super(
|
|
1640
|
+
applicationName,
|
|
1641
|
+
applicationUri,
|
|
1642
|
+
productName,
|
|
1643
|
+
productUri,
|
|
1644
|
+
encoder,
|
|
1645
|
+
decoder,
|
|
1646
|
+
loggerFactory
|
|
1647
|
+
);
|
|
422
1648
|
}
|
|
423
1649
|
};
|
|
424
1650
|
var UserIdentity = class _UserIdentity {
|
|
@@ -464,6 +1690,36 @@ var UserIdentity = class _UserIdentity {
|
|
|
464
1690
|
}
|
|
465
1691
|
};
|
|
466
1692
|
|
|
467
|
-
|
|
1693
|
+
// src/requestOptions.ts
|
|
1694
|
+
var ReturnDiagnosticsMask = {
|
|
1695
|
+
/** All service-level diagnostic fields. */
|
|
1696
|
+
ServiceLevel: 31,
|
|
1697
|
+
/** All operation-level diagnostic fields. */
|
|
1698
|
+
OperationLevel: 992,
|
|
1699
|
+
/** All diagnostic fields (service level + operation level). */
|
|
1700
|
+
All: 1023,
|
|
1701
|
+
/** Service-level: index to SymbolicId in the server string table. */
|
|
1702
|
+
ServiceSymbolicId: 1,
|
|
1703
|
+
/** Service-level: index to LocalizedText in the server string table. */
|
|
1704
|
+
ServiceLocalizedText: 2,
|
|
1705
|
+
/** Service-level: additional info string. */
|
|
1706
|
+
ServiceAdditionalInfo: 4,
|
|
1707
|
+
/** Service-level: inner status code. */
|
|
1708
|
+
ServiceInnerStatusCode: 8,
|
|
1709
|
+
/** Service-level: inner diagnostic info. */
|
|
1710
|
+
ServiceInnerDiagnostics: 16,
|
|
1711
|
+
/** Operation-level: index to SymbolicId in the server string table. */
|
|
1712
|
+
OperationSymbolicId: 32,
|
|
1713
|
+
/** Operation-level: index to LocalizedText in the server string table. */
|
|
1714
|
+
OperationLocalizedText: 64,
|
|
1715
|
+
/** Operation-level: additional info string. */
|
|
1716
|
+
OperationAdditionalInfo: 128,
|
|
1717
|
+
/** Operation-level: inner status code. */
|
|
1718
|
+
OperationInnerStatusCode: 256,
|
|
1719
|
+
/** Operation-level: inner diagnostic info. */
|
|
1720
|
+
OperationInnerDiagnostics: 512
|
|
1721
|
+
};
|
|
1722
|
+
|
|
1723
|
+
export { BrowseNodeResult, CERTIFICATE_REQUIRED_STATUS_CODES, CallMethodResult, CertificateRequiredError, Client, ConfigurationClient, NamespaceTable, ReturnDiagnosticsMask, SECURITY_POLICY_NONE_URI, SessionInvalidError, UserIdentity };
|
|
468
1724
|
//# sourceMappingURL=index.js.map
|
|
469
1725
|
//# sourceMappingURL=index.js.map
|