opcjs-client 0.1.26-alpha → 0.1.32-alpha

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.cjs CHANGED
@@ -92,17 +92,23 @@ var SessionService = class _SessionService extends ServiceBase {
92
92
  if (serviceResult !== void 0 && serviceResult !== opcjsBase.StatusCode.Good) {
93
93
  throw new Error(`CreateSessionRequest failed: ${opcjsBase.StatusCodeToString(serviceResult)}`);
94
94
  }
95
- const endpoint = "opc." + this.secureChannel.getEndpointUrl();
96
- const endpointUrl = new URL(endpoint);
95
+ const clientConnectionUrl = new URL("opc." + this.secureChannel.getEndpointUrl());
97
96
  const securityMode = this.secureChannel.getSecurityMode();
98
97
  const securityPolicyUri = this.secureChannel.getSecurityPolicy();
98
+ const normalizeEndpointUrl = (serverEndpointUrl) => {
99
+ const url = new URL(serverEndpointUrl);
100
+ url.hostname = clientConnectionUrl.hostname;
101
+ url.port = clientConnectionUrl.port;
102
+ return url;
103
+ };
99
104
  const serverEndpoint = castedResponse?.serverEndpoints?.find((currentEndpoint) => {
100
- const currentEndpointUrl = new URL(currentEndpoint.endpointUrl);
101
- return currentEndpointUrl.protocol === endpointUrl.protocol && currentEndpointUrl.pathname === endpointUrl.pathname && currentEndpointUrl.port === endpointUrl.port && currentEndpoint.securityMode === securityMode && currentEndpoint.securityPolicyUri === securityPolicyUri;
105
+ const normalized = normalizeEndpointUrl(currentEndpoint.endpointUrl);
106
+ return normalized.protocol === clientConnectionUrl.protocol && normalized.pathname === clientConnectionUrl.pathname && currentEndpoint.securityMode === securityMode && currentEndpoint.securityPolicyUri === securityPolicyUri;
102
107
  });
103
108
  if (!serverEndpoint) {
104
- throw new Error(`Server endpoint ${endpoint} not found in CreateSessionResponse`);
109
+ throw new Error(`Server endpoint ${clientConnectionUrl.toString()} not found in CreateSessionResponse`);
105
110
  }
111
+ serverEndpoint.endpointUrl = normalizeEndpointUrl(serverEndpoint.endpointUrl).toString();
106
112
  this.logger.debug("Session created with id:", castedResponse.sessionId.identifier);
107
113
  return {
108
114
  sessionId: castedResponse.sessionId.identifier,
@@ -133,6 +139,24 @@ var SessionService = class _SessionService extends ServiceBase {
133
139
  }
134
140
  this.logger.debug("Session activated.");
135
141
  }
142
+ /**
143
+ * Closes the current session on the server (OPC UA Part 4, Section 5.7.4).
144
+ * @param deleteSubscriptions - When true the server deletes all Subscriptions
145
+ * associated with this Session, freeing their resources immediately.
146
+ * Pass false to keep Subscriptions alive for transfer to another Session.
147
+ */
148
+ async closeSession(deleteSubscriptions) {
149
+ this.logger.debug("Sending CloseSessionRequest...");
150
+ const request = new opcjsBase.CloseSessionRequest();
151
+ request.requestHeader = this.createRequestHeader();
152
+ request.deleteSubscriptions = deleteSubscriptions;
153
+ const response = await this.secureChannel.issueServiceRequest(request);
154
+ const result = response?.responseHeader?.serviceResult;
155
+ if (result !== void 0 && result !== opcjsBase.StatusCode.Good) {
156
+ throw new Error(`CloseSession failed: ${opcjsBase.StatusCodeToString(result)}`);
157
+ }
158
+ this.logger.debug("Session closed.");
159
+ }
136
160
  recreate(authToken) {
137
161
  return new _SessionService(authToken, this.secureChannel, this.configuration);
138
162
  }
@@ -194,23 +218,97 @@ var Session = class {
194
218
  getAuthToken() {
195
219
  return this.authToken;
196
220
  }
221
+ getSessionId() {
222
+ return this.sessionId;
223
+ }
224
+ getEndpoint() {
225
+ return this.endpoint;
226
+ }
227
+ /**
228
+ * Closes the session on the server (OPC UA Part 4, Section 5.7.4).
229
+ * @param deleteSubscriptions - When true the server deletes all Subscriptions
230
+ * tied to this Session. Defaults to true.
231
+ */
232
+ async close(deleteSubscriptions = true) {
233
+ await this.sessionServices.closeSession(deleteSubscriptions);
234
+ }
197
235
  };
198
236
 
199
237
  // src/sessions/sessionHandler.ts
200
238
  var SessionHandler = class {
239
+ constructor(secureChannel, configuration) {
240
+ this.configuration = configuration;
241
+ this.sessionServices = new SessionService(opcjsBase.NodeId.newTwoByte(0), secureChannel, configuration);
242
+ }
201
243
  sessionServices;
244
+ logger = opcjsBase.getLogger("sessions.SessionHandler");
202
245
  async createNewSession(identity) {
203
246
  const ret = await this.sessionServices.createSession();
204
247
  this.sessionServices = this.sessionServices.recreate(ret.authToken);
205
248
  const session = new Session(ret.sessionId, ret.authToken, ret.endpoint, this.sessionServices);
249
+ this.validateUserTokenPolicy(identity, ret.endpoint);
206
250
  await session.activateSession(identity);
207
251
  return session;
208
252
  }
209
- constructor(secureChannel, configuration) {
210
- this.sessionServices = new SessionService(opcjsBase.NodeId.newTwoByte(0), secureChannel, configuration);
253
+ /**
254
+ * Attempts to reactivate an existing OPC UA session on the current (new) SecureChannel
255
+ * without calling CreateSession first (OPC UA Part 4, Section 5.7.3).
256
+ *
257
+ * This is the preferred recovery path when the SecureChannel drops but the server-side
258
+ * session has not yet timed out: only a new channel is needed, not a new session.
259
+ *
260
+ * @returns The reactivated Session if ActivateSession succeeded, or `null` if the server
261
+ * rejected the request (e.g. the session had already expired).
262
+ */
263
+ async tryActivateExistingSession(existingAuthToken, existingSessionId, existingEndpoint, identity) {
264
+ const serviceForExistingSession = this.sessionServices.recreate(existingAuthToken);
265
+ try {
266
+ const session = new Session(existingSessionId, existingAuthToken, existingEndpoint, serviceForExistingSession);
267
+ await session.activateSession(identity);
268
+ return session;
269
+ } catch (err) {
270
+ this.logger.debug("ActivateSession for existing session failed:", err);
271
+ return null;
272
+ }
273
+ }
274
+ /**
275
+ * Closes the active session on the server (OPC UA Part 4, Section 5.7.4).
276
+ * @param deleteSubscriptions - Forwarded to CloseSessionRequest. Defaults to true.
277
+ */
278
+ async closeSession(deleteSubscriptions = true) {
279
+ await this.sessionServices.closeSession(deleteSubscriptions);
280
+ }
281
+ /**
282
+ * Validates the requested user-identity token type against:
283
+ * 1. The `allowedUserTokenTypes` from the client security configuration — the
284
+ * client has explicitly restricted which token types it will use.
285
+ * 2. The token policies advertised by the server endpoint — verifies that the
286
+ * server actually supports at least one of the allowed types.
287
+ *
288
+ * Throws with a descriptive message if either check fails.
289
+ */
290
+ validateUserTokenPolicy(identity, endpoint) {
291
+ const allowedTypes = this.configuration.securityConfiguration?.allowedUserTokenTypes;
292
+ if (!allowedTypes) return;
293
+ const requestedType = identity.getTokenType();
294
+ if (!allowedTypes.includes(requestedType)) {
295
+ throw new Error(
296
+ `User token type '${opcjsBase.UserTokenTypeEnum[requestedType]}' is not permitted by the client security configuration. Allowed types: ${allowedTypes.map((t) => opcjsBase.UserTokenTypeEnum[t]).join(", ")}.`
297
+ );
298
+ }
299
+ const serverTypes = endpoint.userIdentityTokens?.map((p) => p.tokenType) ?? [];
300
+ const intersection = allowedTypes.filter((t) => serverTypes.includes(t));
301
+ if (intersection.length === 0) {
302
+ throw new Error(
303
+ `Server endpoint does not offer any user token type from the allowed list: ${allowedTypes.map((t) => opcjsBase.UserTokenTypeEnum[t]).join(", ")}. Server offers: ${serverTypes.map((t) => opcjsBase.UserTokenTypeEnum[t]).join(", ")}.`
304
+ );
305
+ }
211
306
  }
212
307
  };
213
308
 
309
+ // src/securityConfiguration.ts
310
+ var SECURITY_POLICY_NONE_URI = "http://opcfoundation.org/UA/SecurityPolicy#None";
311
+
214
312
  // src/services/attributeServiceAttributes.ts
215
313
  var AttrIdValue = 13;
216
314
 
@@ -285,6 +383,10 @@ var SubscriptionHandler = class {
285
383
  entries = new Array();
286
384
  nextHandle = 0;
287
385
  isRunning = false;
386
+ /** Returns true when at least one subscription is active and the publish loop is running. */
387
+ hasActiveSubscription() {
388
+ return this.isRunning && this.entries.length > 0;
389
+ }
288
390
  async subscribe(ids, callback) {
289
391
  if (this.entries.length > 0) {
290
392
  throw new Error("Subscribing more than once is not implemented");
@@ -327,7 +429,8 @@ var SubscriptionHandler = class {
327
429
  }
328
430
  for (const notificationData of notificationDatas) {
329
431
  const decodedData = notificationData.data;
330
- const typeNodeId = notificationData.typeId;
432
+ const rawTypeId = notificationData.typeId;
433
+ const typeNodeId = rawTypeId instanceof opcjsBase.ExpandedNodeId ? rawTypeId.nodeId : rawTypeId;
331
434
  if (typeNodeId.namespace === 0 && typeNodeId.identifier === NODE_ID_DATA_CHANGE_NOTIFICATION) {
332
435
  const dataChangeNotification = decodedData;
333
436
  for (const item of dataChangeNotification.monitoredItems) {
@@ -540,6 +643,8 @@ var BrowseNodeResult = class {
540
643
  };
541
644
 
542
645
  // src/client.ts
646
+ var SERVER_STATUS_NODE_ID = opcjsBase.NodeId.newNumeric(0, 2256);
647
+ var KEEP_ALIVE_INTERVAL_MS = 25e3;
543
648
  var Client = class {
544
649
  constructor(endpointUrl, configuration, identity) {
545
650
  this.configuration = configuration;
@@ -555,9 +660,12 @@ var Client = class {
555
660
  session;
556
661
  subscriptionHandler;
557
662
  logger;
558
- // Stored after connect() so that refreshSession() can recreate services.
663
+ // Stored after connect() so that refreshSession() and disconnect() can use them.
559
664
  secureChannel;
665
+ secureChannelFacade;
666
+ ws;
560
667
  sessionHandler;
668
+ keepAliveTimer;
561
669
  getSession() {
562
670
  if (!this.session) {
563
671
  throw new Error("No session available");
@@ -586,20 +694,77 @@ var Client = class {
586
694
  * This covers the reactive case: a service call reveals that the server has
587
695
  * already dropped the session (e.g. due to timeout). The new session is
588
696
  * established transparently before re-running the original operation.
697
+ *
698
+ * For any other error (e.g. transport-level failures when the SecureChannel
699
+ * drops), this method attempts to reconnect the channel and reactivate the
700
+ * existing session first — falling back to a brand-new session only when
701
+ * reactivation fails — before retrying the operation once.
589
702
  */
590
703
  async withSessionRefresh(fn) {
591
704
  try {
592
705
  return await fn();
593
706
  } catch (err) {
594
- if (!(err instanceof SessionInvalidError)) throw err;
595
- this.logger.info(`Session invalid (${err.statusCode.toString(16)}), refreshing session...`);
596
- this.session = await this.sessionHandler.createNewSession(this.identity);
597
- this.initServices();
598
- this.logger.info("Session refreshed, retrying operation.");
707
+ if (err instanceof SessionInvalidError) {
708
+ this.logger.info(`Session invalid (${err.statusCode.toString(16)}), refreshing session...`);
709
+ this.session = await this.sessionHandler.createNewSession(this.identity);
710
+ this.initServices();
711
+ this.logger.info("Session refreshed, retrying operation.");
712
+ return await fn();
713
+ }
714
+ this.logger.info("Service call failed, attempting channel reconnect and session reactivation...");
715
+ try {
716
+ await this.reconnectAndReactivate();
717
+ this.initServices();
718
+ this.logger.info("Reconnected successfully, retrying operation.");
719
+ } catch (reconnectErr) {
720
+ this.logger.warn("Channel reconnect failed:", reconnectErr);
721
+ throw err;
722
+ }
599
723
  return await fn();
600
724
  }
601
725
  }
726
+ /**
727
+ * Starts a periodic keep-alive timer that reads Server_ServerStatus when no subscription is
728
+ * active. OPC UA Part 4, Section 5.7.1 requires clients to keep the session alive; when no
729
+ * subscription Publish loop is running this is the only mechanism that does so.
730
+ */
731
+ startKeepAlive() {
732
+ this.keepAliveTimer = setInterval(() => {
733
+ if (this.subscriptionHandler?.hasActiveSubscription()) {
734
+ return;
735
+ }
736
+ if (this.attributeService) {
737
+ void this.attributeService.ReadValue([SERVER_STATUS_NODE_ID]).catch((err) => {
738
+ this.logger.warn("Keep-alive read failed:", err);
739
+ });
740
+ }
741
+ }, KEEP_ALIVE_INTERVAL_MS);
742
+ }
743
+ stopKeepAlive() {
744
+ clearInterval(this.keepAliveTimer);
745
+ this.keepAliveTimer = void 0;
746
+ }
602
747
  async connect() {
748
+ const { ws, sc } = await this.openTransportAndChannel();
749
+ this.secureChannel = sc;
750
+ this.secureChannelFacade = sc;
751
+ this.ws = ws;
752
+ this.logger.debug("Creating session...");
753
+ this.sessionHandler = new SessionHandler(sc, this.configuration);
754
+ this.session = await this.sessionHandler.createNewSession(this.identity);
755
+ this.logger.debug("Session created.");
756
+ this.logger.debug("Initializing services...");
757
+ this.initServices();
758
+ this.startKeepAlive();
759
+ }
760
+ /**
761
+ * Builds the full WebSocket → TCP → SecureChannel pipeline and returns the
762
+ * two objects needed to drive it: the raw WebSocket facade (for teardown)
763
+ * and the SecureChannelFacade (for service requests/session management).
764
+ *
765
+ * Extracted from `connect()` so it can be reused by `reconnectAndReactivate()`.
766
+ */
767
+ async openTransportAndChannel() {
603
768
  const wsOptions = { endpoint: this.endpointUrl };
604
769
  const ws = new opcjsBase.WebSocketFascade(wsOptions);
605
770
  const webSocketReadableStream = new opcjsBase.WebSocketReadableStream(ws, 1e3);
@@ -608,7 +773,7 @@ var Client = class {
608
773
  const tcpMessageInjector = new opcjsBase.TcpMessageInjector();
609
774
  const tcpConnectionHandler = new opcjsBase.TcpConnectionHandler(tcpMessageInjector, scContext);
610
775
  const tcpMessageDecoupler = new opcjsBase.TcpMessageDecoupler(tcpConnectionHandler.onTcpMessage.bind(tcpConnectionHandler));
611
- const scMessageEncoder = new opcjsBase.SecureChannelMesssageEncoder(scContext);
776
+ const scMessageEncoder = new opcjsBase.SecureChannelMessageEncoder(scContext);
612
777
  const scTypeDecoder = new opcjsBase.SecureChannelTypeDecoder(
613
778
  this.configuration.decoder
614
779
  );
@@ -642,16 +807,106 @@ var Client = class {
642
807
  this.logger.debug("Opening secure channel...");
643
808
  await sc.openSecureChannel();
644
809
  this.logger.debug("Secure channel established.");
645
- this.logger.debug("Creating session...");
646
- this.sessionHandler = new SessionHandler(sc, this.configuration);
810
+ this.enforceChannelSecurityConfig(sc);
811
+ return { ws, sc };
812
+ }
813
+ /**
814
+ * Validates the negotiated channel's security policy and mode against the
815
+ * client's `SecurityConfiguration` (OPC UA Part 2, Security Administration).
816
+ *
817
+ * Throws if:
818
+ * - `allowSecurityPolicyNone` is `false` and the channel uses SecurityPolicy None.
819
+ * - `messageSecurityMode` is set and does not match the channel's actual mode.
820
+ */
821
+ enforceChannelSecurityConfig(sc) {
822
+ const config = this.configuration.securityConfiguration;
823
+ if (!config) return;
824
+ const negotiatedPolicy = sc.getSecurityPolicy();
825
+ const negotiatedMode = sc.getSecurityMode();
826
+ if (config.allowSecurityPolicyNone === false && negotiatedPolicy === SECURITY_POLICY_NONE_URI) {
827
+ throw new Error(
828
+ "Connection refused: SecurityPolicy None is disabled by the client security configuration. Only SecurityPolicy None is currently supported by this client implementation."
829
+ );
830
+ }
831
+ if (config.messageSecurityMode !== void 0 && config.messageSecurityMode !== negotiatedMode) {
832
+ throw new Error(
833
+ `Connection refused: negotiated MessageSecurityMode ${negotiatedMode} does not match the required mode ${config.messageSecurityMode} from the security configuration.`
834
+ );
835
+ }
836
+ }
837
+ /**
838
+ * Tears down the current (dead) channel and establishes a fresh one, then
839
+ * attempts to recover the existing OPC UA session via ActivateSession before
840
+ * falling back to a full CreateSession + ActivateSession.
841
+ *
842
+ * OPC UA Part 4, Section 5.7.1 / Session Client Auto Reconnect conformance unit:
843
+ * When the SecureChannel drops but the server-side session has not yet timed
844
+ * out, the client SHOULD reuse the existing session by calling ActivateSession
845
+ * on the new channel. Only if that fails should the client create a new session.
846
+ */
847
+ async reconnectAndReactivate() {
848
+ this.logger.info("Tearing down dead channel before reconnect...");
849
+ try {
850
+ this.secureChannelFacade?.close();
851
+ } catch {
852
+ }
853
+ try {
854
+ this.ws?.close();
855
+ } catch {
856
+ }
857
+ this.secureChannelFacade = void 0;
858
+ this.secureChannel = void 0;
859
+ this.ws = void 0;
860
+ const { ws, sc } = await this.openTransportAndChannel();
647
861
  this.secureChannel = sc;
862
+ this.secureChannelFacade = sc;
863
+ this.ws = ws;
864
+ this.sessionHandler = new SessionHandler(sc, this.configuration);
865
+ if (this.session) {
866
+ const reactivated = await this.sessionHandler.tryActivateExistingSession(
867
+ this.session.getAuthToken(),
868
+ this.session.getSessionId(),
869
+ this.session.getEndpoint(),
870
+ this.identity
871
+ );
872
+ if (reactivated !== null) {
873
+ this.session = reactivated;
874
+ this.logger.info("Existing session successfully reactivated on new channel.");
875
+ return;
876
+ }
877
+ this.logger.info("ActivateSession for existing session failed; creating a fresh session...");
878
+ }
648
879
  this.session = await this.sessionHandler.createNewSession(this.identity);
649
- this.logger.debug("Session created.");
650
- this.logger.debug("Initializing services...");
651
- this.initServices();
880
+ this.logger.info("Fresh session established on new channel.");
652
881
  }
882
+ /**
883
+ * Gracefully disconnects from the OPC UA server.
884
+ *
885
+ * Sequence per OPC UA Part 4, Section 5.7.4:
886
+ * 1. CloseSession (deleteSubscriptions=true) so the server frees all resources.
887
+ * 2. Close the SecureChannel, which cancels the pending token-renewal timer.
888
+ * 3. Close the WebSocket transport.
889
+ *
890
+ * CloseSession errors are swallowed so transport teardown always completes even
891
+ * when the session has already expired on the server side.
892
+ */
653
893
  async disconnect() {
654
894
  this.logger.info("Disconnecting from OPC UA server...");
895
+ this.stopKeepAlive();
896
+ if (this.session && this.sessionHandler) {
897
+ try {
898
+ await this.sessionHandler.closeSession(true);
899
+ } catch (err) {
900
+ this.logger.warn("CloseSession failed (continuing teardown):", err);
901
+ }
902
+ this.session = void 0;
903
+ }
904
+ this.secureChannelFacade?.close();
905
+ this.secureChannelFacade = void 0;
906
+ this.secureChannel = void 0;
907
+ this.ws?.close();
908
+ this.ws = void 0;
909
+ this.logger.info("Disconnected.");
655
910
  }
656
911
  async read(ids) {
657
912
  return this.withSessionRefresh(async () => {
@@ -720,8 +975,8 @@ var Client = class {
720
975
  if (recursive) {
721
976
  for (const ref of allReferences) {
722
977
  const childNodeId = opcjsBase.NodeId.newNumeric(
723
- ref.nodeId.namespace,
724
- ref.nodeId.identifier
978
+ ref.nodeId.nodeId.namespace,
979
+ ref.nodeId.nodeId.identifier
725
980
  );
726
981
  const childResults = await this.browseRecursive(
727
982
  childNodeId,
@@ -738,6 +993,14 @@ var Client = class {
738
993
  }
739
994
  };
740
995
  var ConfigurationClient = class _ConfigurationClient extends opcjsBase.Configuration {
996
+ /**
997
+ * Optional security restrictions applied during `Client.connect()`.
998
+ * When not set, permissive defaults are used (SecurityPolicy None allowed,
999
+ * all user-token types accepted).
1000
+ *
1001
+ * @see SecurityConfiguration
1002
+ */
1003
+ securityConfiguration;
741
1004
  static getSimple(name, company, loggerFactory) {
742
1005
  if (!loggerFactory) {
743
1006
  loggerFactory = new opcjsBase.LoggerFactory({
@@ -830,6 +1093,7 @@ var UserIdentity = class _UserIdentity {
830
1093
  exports.BrowseNodeResult = BrowseNodeResult;
831
1094
  exports.Client = Client;
832
1095
  exports.ConfigurationClient = ConfigurationClient;
1096
+ exports.SECURITY_POLICY_NONE_URI = SECURITY_POLICY_NONE_URI;
833
1097
  exports.SessionInvalidError = SessionInvalidError;
834
1098
  exports.UserIdentity = UserIdentity;
835
1099
  //# sourceMappingURL=index.cjs.map