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/dist/index.js CHANGED
@@ -1,23 +1,87 @@
1
- import { ChannelFactory, SecureChannel, Configuration, Encoder, BinaryWriter, registerEncoders, Decoder, BinaryReader, registerTypeDecoders, registerBinaryDecoders, UserTokenTypeEnum, AnonymousIdentityToken, UserNameIdentityToken, IssuedIdentityToken, NodeId, SubscriptionAcknowledgement, CreateSubscriptionRequest, PublishRequest, ReadValueId, QualifiedName, MonitoringParameters, ExtensionObject, MonitoredItemCreateRequest, MonitoringModeEnum, CreateMonitoredItemsRequest, TimestampsToReturnEnum, ReadRequest, ApplicationDescription, LocalizedText, ApplicationTypeEnum, CreateSessionRequest, CreateSessionResponse, SignatureData, ActivateSessionRequest, RequestHeader } from 'opcjs-base';
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
- createRequestHeader() {
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 = 0;
14
- requestHeader.returnDiagnostics = 0;
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
- async createSession() {
29
- console.log("Creating session...");
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
- console.log("Sending CreateSessionRequest...");
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 endpoint = "opc." + this.secureChannel.getEndpointUrl();
58
- const endpointUrl = new URL(endpoint);
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 currentEndpointUrl = new URL(currentEndpoint.endpointUrl);
63
- return currentEndpointUrl.protocol === endpointUrl.protocol && currentEndpointUrl.pathname === endpointUrl.pathname && currentEndpointUrl.port === endpointUrl.port && currentEndpoint.securityMode === securityMode && currentEndpoint.securityPolicyUri === securityPolicyUri;
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 ${endpoint} not found in CreateSessionResponse`);
154
+ throw new Error(`Server endpoint ${clientConnectionUrl.toString()} not found in CreateSessionResponse`);
67
155
  }
68
- console.log("Session created with id:", castedResponse.sessionId.identifier);
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
- console.log("Sending ActivateSessionRequest...");
87
- await this.secureChannel.issueServiceRequest(request);
88
- console.log("Session activated.");
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
- const ret = await this.sessionServices.createSession();
158
- this.sessionServices = this.sessionServices.recreate(ret.authToken);
159
- const session = new Session(ret.sessionId, ret.authToken, ret.endpoint, this.sessionServices);
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
- constructor(secureChannel, configuration) {
164
- this.sessionServices = new SessionService(NodeId.newTwoByte(0), secureChannel, configuration);
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
- async ReadValue(nodeIds) {
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 = 6e4;
185
- request.timestampsToReturn = TimestampsToReturnEnum.Source;
455
+ request.requestHeader = this.createRequestHeader(returnDiagnostics, requestHandle);
456
+ request.maxAge = maxAge;
457
+ request.timestampsToReturn = timestampsToReturn;
186
458
  request.nodesToRead = readValueIds;
187
- console.log("Sending ReadRequest...");
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
- for (let dataValue of response.results ?? []) {
191
- const result = {
192
- status: dataValue.statusCode?.toString() ?? "Unknown",
193
- value: dataValue.value
194
- };
195
- results.push(result);
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, status) {
481
+ constructor(value, statusCode, diagnosticInfo) {
207
482
  this.value = value;
208
- this.status = status;
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
- async subscribe(ids, callback) {
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 (let id of ids) {
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
- const item = {
245
- id,
246
- handle: entry.handle
247
- };
248
- items.push(item);
249
- }
250
- await this.monitoredItemService.createMonitoredItems(subscriptionId, items);
251
- this.publish([]);
252
- }
253
- async publish(acknowledgeSequenceNumbers) {
254
- const acknowledgements = [];
255
- for (let i = 0; i < acknowledgeSequenceNumbers.length; i++) {
256
- const acknowledgement = new SubscriptionAcknowledgement();
257
- acknowledgement.subscriptionId = this.entries[i].subscriptionId;
258
- acknowledgement.sequenceNumber = acknowledgeSequenceNumbers[i];
259
- acknowledgements.push(acknowledgement);
260
- }
261
- const response = await this.subscriptionService.publish(acknowledgements);
262
- const messagesToAcknowledge = response.notificationMessage.sequenceNumber;
263
- const notificationDatas = response.notificationMessage.notificationData;
264
- for (let notificationData of notificationDatas) {
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 typeNodeId = notificationData.typeId;
267
- if (typeNodeId.namespace === 0 && typeNodeId.identifier === 811) {
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 (let item of dataChangeNotification.monitoredItems) {
270
- const clientHandle = item.clientHandle;
271
- const value = item.value;
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
- console.log(`The change notification data type ${typeNodeId.namespace}:${typeNodeId.identifier} is not supported.`);
592
+ this.logger.warn(
593
+ `Notification data type ${typeNodeId.namespace}:${typeNodeId.identifier} is not supported.`
594
+ );
280
595
  }
281
596
  }
282
- setTimeout(() => {
283
- this.publish([messagesToAcknowledge]);
284
- }, 500);
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
- console.log("Sending createSubscription...");
620
+ request.priority = options?.priority ?? 1;
621
+ this.logger.debug("Sending CreateSubscriptionRequest...");
299
622
  const response = await this.secureChannel.issueServiceRequest(request);
300
- console.log("Subscription created with id:", response.subscriptionId);
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
- console.log("Sending publish...");
640
+ this.logger.debug("Sending PublishRequest...");
309
641
  const response = await this.secureChannel.issueServiceRequest(request);
310
- console.log("Received publish response.");
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
- async createMonitoredItems(subscriptionId, ids) {
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 = 1e3;
671
+ monitoringParameters.samplingInterval = samplingInterval;
328
672
  monitoringParameters.filter = ExtensionObject.newEmpty();
329
- monitoringParameters.queueSize = 100;
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
- console.log("Sending createMonitoredItems...");
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
- channel;
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 channel = ChannelFactory.createChannel(this.endpointUrl);
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
- console.log(`Connecting to OPC UA server at ${this.endpointUrl}...`);
372
- connected = await channel.connect();
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
- console.log("Connection failed, retrying in 2 seconds...");
1176
+ this.logger.info("Connection failed, retrying in 2 seconds...");
375
1177
  await new Promise((resolve) => setTimeout(resolve, 2e3));
376
1178
  }
377
1179
  }
378
- console.log("Connected to OPC UA server.");
379
- this.channel = new SecureChannel(channel, this.configuration);
380
- await this.channel.openSecureChannelRequest();
381
- const sessionHandler = new SessionHandler(this.channel, this.configuration);
382
- this.session = await sessionHandler.createNewSession(this.identity);
383
- this.subscriptionHandler = new SubscriptionHandler(
384
- new SubscriptionService(this.session.getAuthToken(), this.channel),
385
- new MonitoredItemService(this.session.getAuthToken(), this.channel)
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
- console.log("Disconnecting from OPC UA server...");
390
- if (this.channel) {
391
- await this.channel.disconnect();
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
- async read(ids) {
395
- const service = new AttributeService(this.getSession().getAuthToken(), this.channel);
396
- const result = await service.ReadValue(ids);
397
- return result.map((r) => new ReadValueResult(r.value, r.status));
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
- async subscribe(ids, callback) {
400
- this.subscriptionHandler?.subscribe(ids, callback);
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
- static getSimple(name, company) {
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(name, applicationUri, name, productUri, encoder, decoder);
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(applicationName, applicationUri, productName, productUri, encoder, decoder);
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
- export { Client, ConfigurationClient, UserIdentity };
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