@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.
- package/CHANGELOG.md +19 -0
- package/CONTRIBUTING.md +36 -0
- package/LICENSE +21 -0
- package/README.md +506 -0
- package/docs/assets/client-quickstart-flow.png +0 -0
- package/docs/assets/endpoint-security-config.png +0 -0
- package/docs/assets/node-red-import-examples-menu.png +0 -0
- package/docs/assets/server-client-demo-flow.png +0 -0
- package/docs/assets/server-quickstart-flow.png +0 -0
- package/docs/assets/server-security-config.png +0 -0
- package/docs/assets/subscribe-deadband-config.png +0 -0
- package/examples/client-quickstart.json +146 -0
- package/examples/server-and-client-demo.json +197 -0
- package/examples/server-quickstart.json +83 -0
- package/nodes/lib/connection-manager.js +489 -0
- package/nodes/lib/server-manager.js +343 -0
- package/nodes/opcua-browse.html +46 -0
- package/nodes/opcua-browse.js +59 -0
- package/nodes/opcua-endpoint.html +122 -0
- package/nodes/opcua-endpoint.js +85 -0
- package/nodes/opcua-read.html +47 -0
- package/nodes/opcua-read.js +86 -0
- package/nodes/opcua-server.html +191 -0
- package/nodes/opcua-server.js +105 -0
- package/nodes/opcua-subscribe.html +84 -0
- package/nodes/opcua-subscribe.js +124 -0
- package/nodes/opcua-write.html +63 -0
- package/nodes/opcua-write.js +110 -0
- package/package.json +63 -0
|
@@ -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 };
|