@jsgorana/node-red-opcua 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,489 @@
1
+ "use strict";
2
+
3
+ const os = require("os");
4
+ const path = require("path");
5
+ const {
6
+ OPCUAClient,
7
+ OPCUACertificateManager,
8
+ AttributeIds,
9
+ ClientSubscription,
10
+ MonitoringMode,
11
+ MessageSecurityMode,
12
+ SecurityPolicy,
13
+ TimestampsToReturn,
14
+ UserTokenType
15
+ } = require("node-opcua");
16
+
17
+ /** Security policies deprecated by the OPC Foundation — discouraged. */
18
+ const DEPRECATED_POLICIES = ["Basic128Rsa15", "Basic256"];
19
+
20
+ /**
21
+ * Ref-counted OPC-UA connection manager.
22
+ *
23
+ * One instance is shared by every operation node attached to a single
24
+ * `opcua-endpoint` config node, so they reuse one OPCUAClient + ClientSession.
25
+ * Handles automatic reconnection and reports state changes to listeners.
26
+ *
27
+ * This module has no Node-RED dependency — it is a standalone library so it
28
+ * can be unit-tested in isolation.
29
+ */
30
+ class ConnectionManager {
31
+ /**
32
+ * @param {object} config
33
+ * @param {string} config.endpointUrl e.g. "opc.tcp://localhost:4840"
34
+ * @param {string} [config.securityMode] "None" | "Sign" | "SignAndEncrypt"
35
+ * @param {string} [config.securityPolicy] key of SecurityPolicy, e.g. "Basic256Sha256"
36
+ * @param {string} [config.authType] "anonymous" | "username"
37
+ * @param {string} [config.username]
38
+ * @param {string} [config.password]
39
+ * @param {string} [config.applicationName]
40
+ */
41
+ constructor(config) {
42
+ this.config = config || {};
43
+ this.applicationName = this.config.applicationName || "node-red-opcua";
44
+
45
+ // Max time a single operation (incl. waiting for a session) will block
46
+ // before failing fast. 0 disables the timeout. The background client
47
+ // keeps retrying regardless, so subscriptions still recover.
48
+ const t = parseInt(this.config.timeout, 10);
49
+ this.timeout = (Number.isFinite(t) && t >= 0) ? t : 10000;
50
+
51
+ // PKI: where the client's application certificate and the trusted/rejected
52
+ // server certificate lists live. Persisted so the client identity and
53
+ // trust decisions survive restarts.
54
+ this.pkiFolder = this.config.pkiFolder ||
55
+ path.join(os.tmpdir(), "node-red-opcua-pki");
56
+
57
+ // Secure by default: unknown server certificates are REJECTED (they land
58
+ // in the PKI "rejected" folder to be trusted explicitly). Only set true
59
+ // for development — it disables server-certificate validation.
60
+ this.acceptUntrusted = this.config.acceptUntrusted === true;
61
+
62
+ this._certManager = null;
63
+
64
+ this.client = null;
65
+ this.session = null;
66
+ this.refCount = 0;
67
+ this.state = "closed";
68
+ this.lastError = null;
69
+ this.listeners = [];
70
+
71
+ /** @type {Promise<void>|null} in-flight connect promise for dedup */
72
+ this._connecting = null;
73
+
74
+ this._sharedSubscription = null;
75
+ this._sharedSubscriptionStarting = null;
76
+ this._sharedSubscriptionEpoch = 0;
77
+ }
78
+
79
+ /** @returns {object} node-opcua userIdentity token */
80
+ buildUserIdentity() {
81
+ if (this.config.authType === "username") {
82
+ return {
83
+ type: UserTokenType.UserName,
84
+ userName: this.config.username,
85
+ password: this.config.password
86
+ };
87
+ }
88
+ return { type: UserTokenType.Anonymous };
89
+ }
90
+
91
+ /** @returns {number} MessageSecurityMode (defaults to None) */
92
+ mapSecurityMode() {
93
+ const mode = MessageSecurityMode[this.config.securityMode];
94
+ return mode || MessageSecurityMode.None;
95
+ }
96
+
97
+ /** @returns {string} SecurityPolicy (defaults to None) */
98
+ mapSecurityPolicy() {
99
+ const policy = SecurityPolicy[this.config.securityPolicy];
100
+ return policy || SecurityPolicy.None;
101
+ }
102
+
103
+ /**
104
+ * Lazily create + initialize the client certificate manager. Server
105
+ * certificates are validated against this store; unknown ones are rejected
106
+ * unless `acceptUntrusted` is set (development only).
107
+ * @private
108
+ * @returns {Promise<OPCUACertificateManager>}
109
+ */
110
+ async _getCertificateManager() {
111
+ if (this._certManager) return this._certManager;
112
+ const mgr = new OPCUACertificateManager({
113
+ rootFolder: this.pkiFolder,
114
+ automaticallyAcceptUnknownCertificate: this.acceptUntrusted
115
+ });
116
+ await mgr.initialize();
117
+ this._certManager = mgr;
118
+ return mgr;
119
+ }
120
+
121
+ /**
122
+ * Best-practice advisories about the current security configuration.
123
+ * @returns {string[]}
124
+ */
125
+ securityWarnings() {
126
+ const warnings = [];
127
+ const policy = this.config.securityPolicy;
128
+ const mode = this.config.securityMode;
129
+
130
+ if (DEPRECATED_POLICIES.includes(policy)) {
131
+ warnings.push("Security policy '" + policy + "' is deprecated by the OPC " +
132
+ "Foundation; prefer Basic256Sha256, Aes128_Sha256_RsaOaep or Aes256_Sha256_RsaPss.");
133
+ }
134
+ if (this.config.authType === "username" && (!mode || mode === "None")) {
135
+ warnings.push("Username/password is being used over an unencrypted channel " +
136
+ "(SecurityMode None). The token is encrypted with the server certificate, " +
137
+ "but SecurityMode SignAndEncrypt is recommended for credentials.");
138
+ }
139
+ if (mode && mode !== "None" && this.acceptUntrusted) {
140
+ warnings.push("Accepting untrusted server certificates is insecure and intended " +
141
+ "for development only. Disable it and trust the server certificate explicitly " +
142
+ "for production.");
143
+ }
144
+ return warnings;
145
+ }
146
+
147
+ /**
148
+ * Connect to the server and open a session. Idempotent: concurrent calls
149
+ * share a single in-flight promise; a successful prior connect resolves
150
+ * immediately.
151
+ * @returns {Promise<void>}
152
+ */
153
+ async connect() {
154
+ if (this.state === "connected" && this.session) {
155
+ return;
156
+ }
157
+ if (this._connecting) {
158
+ return this._connecting;
159
+ }
160
+ this._connecting = this._doConnect();
161
+ try {
162
+ await this._connecting;
163
+ } finally {
164
+ this._connecting = null;
165
+ }
166
+ }
167
+
168
+ /** @private actual connect/session work */
169
+ async _doConnect() {
170
+ this._setState("connecting");
171
+ try {
172
+ if (!this.client) {
173
+ const options = {
174
+ applicationName: this.applicationName,
175
+ clientName: this.config.endpointUrl,
176
+ endpointMustExist: false,
177
+ securityMode: this.mapSecurityMode(),
178
+ securityPolicy: this.mapSecurityPolicy(),
179
+ keepSessionAlive: true,
180
+ connectionStrategy: {
181
+ maxRetry: -1,
182
+ initialDelay: 1000,
183
+ maxDelay: 20000
184
+ }
185
+ };
186
+
187
+ // Only a secure channel needs certificates. Using a persisted,
188
+ // validating certificate manager is what enforces server-cert trust.
189
+ if (this.mapSecurityPolicy() !== SecurityPolicy.None) {
190
+ options.clientCertificateManager = await this._getCertificateManager();
191
+ }
192
+
193
+ this.client = OPCUAClient.create(options);
194
+ this._wireClientEvents(this.client);
195
+ }
196
+
197
+ await this.client.connect(this.config.endpointUrl);
198
+ this.session = await this.client.createSession(this.buildUserIdentity());
199
+ this.lastError = null;
200
+ this._setState("connected");
201
+ } catch (err) {
202
+ this.lastError = err;
203
+ this._setState("error", err);
204
+ throw err;
205
+ }
206
+ }
207
+
208
+ /** @private wire auto-reconnect lifecycle events to state changes */
209
+ _wireClientEvents(client) {
210
+ client.on("connection_lost", () => {
211
+ this._dropSharedSubscription();
212
+ this._setState("reconnecting");
213
+ });
214
+ client.on("start_reconnection", () => {
215
+ this._dropSharedSubscription();
216
+ this._setState("reconnecting");
217
+ });
218
+ client.on("connection_reestablished", () => this._setState("connected"));
219
+ // Surfaced for diagnostics; never throw inside handlers.
220
+ client.on("backoff", (_retry, delay) => {
221
+ const suffix = delay ? " (retrying in " + delay + "ms)" : "";
222
+ this.lastError = new Error("OPC-UA server unreachable" + suffix);
223
+ this._setState(this.session ? "reconnecting" : "error", this.lastError);
224
+ });
225
+ client.on("reconnection_attempt_has_failed", (err) => {
226
+ this.lastError = err;
227
+ });
228
+ }
229
+
230
+ /**
231
+ * Ensure connected, then return the live session.
232
+ * @returns {Promise<import("node-opcua").ClientSession>}
233
+ */
234
+ async getSession() {
235
+ if (this.state === "connected" && this.session) {
236
+ return this.session;
237
+ }
238
+ // The node-opcua client auto-reconnects internally and resumes the same
239
+ // session. While that is happening, calling connect() again throws
240
+ // ("invalid internal state = reconnecting"), so instead we wait for the
241
+ // connection to come back. runWithTimeout() bounds how long we wait.
242
+ if (this.client && (this.state === "reconnecting" || this._connecting)) {
243
+ await this._waitUntilConnected();
244
+ return this.session;
245
+ }
246
+ await this.connect();
247
+ return this.session;
248
+ }
249
+
250
+ /**
251
+ * Resolve once the connection reaches "connected"; reject if it is torn down
252
+ * or the wait exceeds the configured timeout. Always removes its listener and
253
+ * timer so it can never leak (the previous version leaked a listener on every
254
+ * timed-out wait during a sustained outage).
255
+ * @private
256
+ * @returns {Promise<void>}
257
+ */
258
+ _waitUntilConnected() {
259
+ if (this.state === "connected" && this.session) {
260
+ return Promise.resolve();
261
+ }
262
+ return new Promise((resolve, reject) => {
263
+ let unsubscribe;
264
+ let timer;
265
+ const cleanup = () => {
266
+ if (unsubscribe) unsubscribe();
267
+ if (timer) clearTimeout(timer);
268
+ };
269
+ unsubscribe = this.onState((state) => {
270
+ if (state === "connected") {
271
+ cleanup();
272
+ resolve();
273
+ } else if (state === "closed") {
274
+ cleanup();
275
+ reject(new Error("OPC-UA connection closed while waiting to reconnect"));
276
+ }
277
+ });
278
+ if (this.timeout && this.timeout > 0) {
279
+ timer = setTimeout(() => {
280
+ cleanup();
281
+ reject(new Error("Timed out waiting for OPC-UA reconnection after " +
282
+ this.timeout + "ms"));
283
+ }, this.timeout);
284
+ }
285
+ });
286
+ }
287
+
288
+ /**
289
+ * Run an operation with the configured fail-fast timeout. The operation is
290
+ * supplied as a factory so it only starts when called. On timeout the
291
+ * returned promise rejects, but the underlying client keeps reconnecting.
292
+ * @param {() => Promise<any>} factory
293
+ * @param {string} [label]
294
+ * @returns {Promise<any>}
295
+ */
296
+ runWithTimeout(factory, label) {
297
+ if (!this.timeout || this.timeout <= 0) {
298
+ return factory();
299
+ }
300
+ let timer;
301
+ const timeoutPromise = new Promise((_resolve, reject) => {
302
+ timer = setTimeout(() => {
303
+ reject(new Error(
304
+ (label || "OPC-UA operation") + " timed out after " +
305
+ this.timeout + "ms (server unreachable?)"
306
+ ));
307
+ }, this.timeout);
308
+ });
309
+ return Promise.race([factory(), timeoutPromise]).finally(() => clearTimeout(timer));
310
+ }
311
+
312
+ /**
313
+ * Add a monitored Value item to the endpoint's shared subscription.
314
+ * @param {object} options
315
+ * @param {string} options.nodeId
316
+ * @param {number} options.samplingInterval
317
+ * @param {number} options.publishingInterval used when creating the shared subscription
318
+ * @param {number} [options.queueSize]
319
+ * @param {import("node-opcua").DataChangeFilter} [options.filter]
320
+ * @param {(dataValue:any) => void} options.onChanged
321
+ * @param {(err:Error) => void} [options.onError]
322
+ * @returns {Promise<{terminate: () => Promise<void>}>}
323
+ */
324
+ async monitorValue(options) {
325
+ const subscription = await this._getSharedSubscription(options);
326
+ const monitoringParameters = {
327
+ samplingInterval: options.samplingInterval,
328
+ discardOldest: true,
329
+ queueSize: options.queueSize || 10
330
+ };
331
+ if (options.filter) {
332
+ monitoringParameters.filter = options.filter;
333
+ }
334
+
335
+ const monitoredItem = await subscription.monitor(
336
+ { nodeId: options.nodeId, attributeId: AttributeIds.Value },
337
+ monitoringParameters,
338
+ TimestampsToReturn.Both,
339
+ MonitoringMode.Reporting
340
+ );
341
+ monitoredItem.on("changed", options.onChanged);
342
+ if (options.onError) {
343
+ monitoredItem.on("err", options.onError);
344
+ }
345
+
346
+ return {
347
+ terminate: async () => {
348
+ monitoredItem.removeListener("changed", options.onChanged);
349
+ if (options.onError) {
350
+ monitoredItem.removeListener("err", options.onError);
351
+ }
352
+ try { await monitoredItem.terminate(); } catch (_e) { /* ignore */ }
353
+ }
354
+ };
355
+ }
356
+
357
+ /** @private */
358
+ async _getSharedSubscription(options) {
359
+ if (this._sharedSubscription) return this._sharedSubscription;
360
+ if (this._sharedSubscriptionStarting) return this._sharedSubscriptionStarting;
361
+
362
+ const epoch = this._sharedSubscriptionEpoch;
363
+ this._sharedSubscriptionStarting = (async () => {
364
+ const session = await this.getSession();
365
+ const subscription = ClientSubscription.create(session, {
366
+ requestedPublishingInterval: options.publishingInterval,
367
+ requestedLifetimeCount: 100,
368
+ requestedMaxKeepAliveCount: 10,
369
+ maxNotificationsPerPublish: 100,
370
+ publishingEnabled: true,
371
+ priority: 10
372
+ });
373
+ subscription.on("internal_error", (err) => {
374
+ this.lastError = err;
375
+ });
376
+ if (epoch !== this._sharedSubscriptionEpoch) {
377
+ try { await subscription.terminate(); } catch (_e) { /* ignore */ }
378
+ throw new Error("OPC-UA shared subscription was reset while starting");
379
+ }
380
+ this._sharedSubscription = subscription;
381
+ return subscription;
382
+ })();
383
+
384
+ try {
385
+ return await this._sharedSubscriptionStarting;
386
+ } finally {
387
+ this._sharedSubscriptionStarting = null;
388
+ }
389
+ }
390
+
391
+ /** @private */
392
+ _dropSharedSubscription() {
393
+ this._sharedSubscriptionEpoch++;
394
+ const subscription = this._sharedSubscription;
395
+ this._sharedSubscription = null;
396
+ this._sharedSubscriptionStarting = null;
397
+ if (subscription) {
398
+ try { subscription.terminate(); } catch (_e) { /* ignore */ }
399
+ }
400
+ }
401
+
402
+ /** Register interest in this connection (increments ref count). */
403
+ acquire() {
404
+ this.refCount++;
405
+ }
406
+
407
+ /**
408
+ * Release interest. When the last consumer releases, the connection is
409
+ * torn down.
410
+ * @returns {Promise<void>}
411
+ */
412
+ async release() {
413
+ this.refCount--;
414
+ if (this.refCount <= 0) {
415
+ this.refCount = 0;
416
+ await this.disconnect();
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Close the session and disconnect the client. Safe to call multiple times.
422
+ * @returns {Promise<void>}
423
+ */
424
+ async disconnect() {
425
+ const session = this.session;
426
+ const client = this.client;
427
+ this._dropSharedSubscription();
428
+ this.session = null;
429
+ this.client = null;
430
+ try {
431
+ if (session) {
432
+ await session.close();
433
+ }
434
+ if (client) {
435
+ await client.disconnect();
436
+ }
437
+ this._setState("closed");
438
+ } catch (err) {
439
+ this.lastError = err;
440
+ this._setState("error", err);
441
+ }
442
+ }
443
+
444
+ /** @returns {object} lightweight runtime diagnostics */
445
+ diagnostics() {
446
+ return {
447
+ state: this.state,
448
+ endpointUrl: this.config.endpointUrl,
449
+ securityMode: this.config.securityMode || "None",
450
+ securityPolicy: this.config.securityPolicy || "None",
451
+ authType: this.config.authType || "anonymous",
452
+ refCount: this.refCount,
453
+ hasClient: !!this.client,
454
+ hasSession: !!this.session,
455
+ listenerCount: this.listeners.length,
456
+ hasSharedSubscription: !!this._sharedSubscription,
457
+ lastError: this.lastError ? this.lastError.message : null
458
+ };
459
+ }
460
+
461
+ /**
462
+ * Register a state listener.
463
+ * @param {(state: string, info?: any) => void} listener
464
+ * @returns {() => void} unsubscribe function
465
+ */
466
+ onState(listener) {
467
+ this.listeners.push(listener);
468
+ let active = true;
469
+ return () => {
470
+ if (!active) return;
471
+ active = false;
472
+ this.listeners = this.listeners.filter((l) => l !== listener);
473
+ };
474
+ }
475
+
476
+ /** @private emit a state change to all listeners */
477
+ _setState(state, info) {
478
+ this.state = state;
479
+ for (const listener of this.listeners.slice()) {
480
+ try {
481
+ listener(state, info);
482
+ } catch (_e) {
483
+ // a misbehaving listener must not break the manager
484
+ }
485
+ }
486
+ }
487
+ }
488
+
489
+ module.exports = { ConnectionManager };