@tier0/opcua-client 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/BENCHMARK.md ADDED
@@ -0,0 +1,83 @@
1
+ # OPC UA client benchmark
2
+
3
+ Recorded on 2026-08-05 in the Node-RED 4.1.2 container used for development, with Node.js 20.19.6, open62541 1.4.16, security mode `None`, and a Python OPC UA server running in the same container over loopback.
4
+
5
+ The benchmark uses real OPC UA service calls. It browses every variable, reads and writes the full point set in batches of 500, creates one Subscription containing all MonitoredItems, waits for the final value of every point, and removes all MonitoredItems during cleanup.
6
+
7
+ | Scenario | Browse | Read P50 | Write P50 / P95 | Subscribe | Notifications | Peak RSS |
8
+ | --- | ---: | ---: | ---: | ---: | ---: | ---: |
9
+ | 1,000 points, 3 reads, 2 writes | 431 ms | 60 ms | 77 / 77 ms | 79 ms | 3,000 | 54.9 MB |
10
+ | 5,000 points, 5 reads, 2 writes | 2,143 ms | 301 ms | 374 / 374 ms | 397 ms | 15,000 | 73.4 MB |
11
+ | 5,000 points, 5 reads, 20 writes | 2,085 ms | 300 ms | 375 / 494 ms | 450 ms | 105,000 | 88.6 MB |
12
+ | 10,000 points, 5 reads, 5 writes | 4,150 ms | 618 ms | 766 / 998 ms | 867 ms | 60,000 | 95.6 MB |
13
+
14
+ All scenarios completed without bad OPC UA status codes. The sustained scenario converged to the final value for 5,000/5,000 points; the capacity scenario converged for 10,000/10,000 points.
15
+
16
+ The 10,000-point throughput was approximately 2,410 browsed points/s, 16,183 read points/s, 13,063 written points/s, and 11,534 monitored items/s. Event-loop delay P95 was 20.7 ms and maximum delay was 56.2 ms.
17
+
18
+ ## Reproduce
19
+
20
+ ```bash
21
+ OPCUA_STRESS_POINTS=10000 \
22
+ OPCUA_STRESS_READ_ROUNDS=5 \
23
+ OPCUA_STRESS_WRITE_ROUNDS=5 \
24
+ OPCUA_STRESS_NOTIFY_TIMEOUT=180000 \
25
+ npm run test:stress
26
+ ```
27
+
28
+ These are local-loopback engineering baselines, not guarantees for a customer network. Production acceptance should repeat the same test against the target server, network latency, security policy, server OperationLimits, sampling interval, and expected value-change rate.
29
+
30
+ ## Node-RED deployed Flow baseline
31
+
32
+ `npm run test:nodered` deploys a temporary isolated Flow into the running Node-RED editor. Requests travel through Node-RED HTTP In/Response, Function, Browse, Read, Write, and Subscribe node instances. Read receives an imported JSON point list and Write receives CSV directly, without a separate normalization node. The controller verifies final subscription convergence, clears the subscription, removes the temporary Flow, and asserts that the original Flow set is restored.
33
+
34
+ | Scenario | Browse | Read P50 / P95 | Write P50 / P95 | Subscribe | Final convergence | Node-RED peak RSS |
35
+ | --- | ---: | ---: | ---: | ---: | ---: | ---: |
36
+ | 100 points, smoke | 65 ms | 18 / 18 ms | 20 / 20 ms | 23 ms | 100/100 | 111.0 MB |
37
+ | 1,000 points, direct JSON Read / CSV Write | 471 ms | 56 / 56 ms | 68 / 68 ms | 75 ms | 1,000/1,000 | 112.4 MB |
38
+ | 5,000 points, 5 reads, 20 writes | 1,939 ms | 301 / 327 ms | 377 / 535 ms | 391 ms | 5,000/5,000 | 202.9 MB |
39
+ | 10,000 points, 5 reads, 5 writes | 4,131 ms | 635 / 671 ms | 764 / 1,069 ms | 806 ms | 10,000/10,000 | 188.3 MB |
40
+
41
+ The 5,000-point sustained Flow performed 100,000 writes. Its collector processed 101,001 post-setup data-change messages and converged every point to the final round. The 10,000-point Flow processed 51,000 post-setup messages and also converged every point. Initial subscription notifications can arrive before the collector receives the subscription-status message, so the reported notification total is not used as a loss assertion; final value convergence is the correctness gate.
42
+
43
+ The Node-RED editor was opened during the 5,000-point run. The temporary tab showed the real nodes with `5000 points`, `5000 read`, and `5000 written` statuses before automatic cleanup.
44
+
45
+ The selectable Browse tree endpoint was also exercised against the real fixture. The 1,000-point run returned 1,002 tree nodes containing exactly 1,000 variables. A saved three-variable selection produced exactly those three NodeIds from the deployed Browse node. In the editor, selecting the 100-point root selected 100/100 variables; clearing one leaf changed the count to 99/100 and placed both the area and root checkboxes into the half-selected state. Reopening the node retained all 99 selections.
46
+
47
+ ## Automatic reconnect and subscription reconstruction
48
+
49
+ The fault-injection test deploys the same real Node-RED Flow, creates a 1,000-item subscription, stops the OPC UA fixture, waits while the connection supervisor replaces terminal clients with bounded backoff, restarts the server on the same endpoint, and requires the Subscribe node to validate and rebuild its lost server-side subscription before writes continue.
50
+
51
+ The recorded run used three reconnect attempts and recovered in 3,458 ms. The Subscribe node rebuilt all 1,000/1,000 MonitoredItems. Two subsequent 1,000-point write rounds delivered all 3,000 notifications (including initial values), converged 1,000/1,000 final values, dropped zero changes, and reached a maximum native queue depth of 944. The temporary Flow was removed and the original Node-RED flow set was verified unchanged.
52
+
53
+ ## Native notification batching optimization
54
+
55
+ The P0 optimization replaced one C++/JavaScript callback per value with a bounded native queue, batched callbacks, queue diagnostics, and zero-copy reuse of the native JavaScript change object in the Subscribe node. Both comparisons use Node-RED `batch` output mode.
56
+
57
+ | Scenario | Metric | Before | After | Change |
58
+ | --- | --- | ---: | ---: | ---: |
59
+ | 5,000 points, 20 writes | Node-RED CPU | 4,110 ms | 2,050 ms | -50.1% |
60
+ | 5,000 points, 20 writes | Peak RSS | 202.9 MB | 143.1 MB | -29.5% |
61
+ | 5,000 points, 20 writes | Native callbacks | about 105,000 | 279 batches | -99.7% |
62
+ | 10,000 points, 5 writes | Node-RED CPU | 3,540 ms | 2,570 ms | -27.4% |
63
+ | 10,000 points, 5 writes | Peak RSS | 188.3 MB | 151.6 MB | -19.5% |
64
+ | 10,000 points, 5 writes | Native callbacks | about 60,000 | 160 batches | -99.7% |
65
+
66
+ In the optimized 5,000-point test, all 105,000 native notifications were delivered with zero drops, maximum native queue depth 1,667, and reported maximum batch lag 0.45 ms. In the optimized 10,000-point test, all 60,000 notifications were delivered with zero drops, maximum depth 1,526, and maximum lag 0.29 ms. Both tests converged every point to its final value.
67
+
68
+ The native queue integration test also intentionally uses capacity 5 and batch size 2 while blocking JavaScript. It verifies a hard maximum depth of 5 and an explicit dropped count of 15. A separate `latest` test coalesces repeated values to one 20-item batch with zero drops.
69
+
70
+ ## OperationLimits and NodeId cache optimization
71
+
72
+ The P1 optimization reads the four standard server limits in one request and adds a bounded per-connection native parsed-NodeId cache. The 5,000-point standalone test recorded 5,011 cache entries, 65,001 hits, 5,011 misses, zero bypasses, and approximately 92.8% hit rate. Browse improved from 2,143 ms to 1,991 ms in the recorded local run; Read P50 remained 301 ms and Write P50 remained within local-run variance at 376 ms.
73
+
74
+ The optimized Node-RED Flow results were:
75
+
76
+ | Scenario | Result | Cache | Node-RED CPU | Peak RSS |
77
+ | --- | --- | --- | ---: | ---: |
78
+ | 5,000 points, 5 reads, 20 writes | 105,000/105,000 notifications, 0 dropped, final 5,000/5,000 | 5,011 entries, 155,001 hits, 0 evictions | 1,990 ms | 139.1 MB |
79
+ | 10,000 points, 5 reads, 5 writes | 60,000/60,000 notifications, 0 dropped, final 10,000/10,000 | 10,021 entries, 160,001 hits, 0 evictions | 2,120 ms | 155.3 MB warm process |
80
+
81
+ Against the immediately preceding P0 runs, 5,000-point Node-RED CPU changed from 2,050 ms to 1,990 ms and peak RSS from 143.1 MB to 139.1 MB. The 10,000-point CPU changed from 2,570 ms to 2,120 ms. The 10,000-point P1 run followed the sustained 5,000-point run in the same Node-RED process, so its 155.3 MB peak is a warmed-process regression value and should not be compared directly with the fresh-process memory baseline.
82
+
83
+ The native integration fixture also advertises all four OperationLimits as 7 and verifies that the client reads those exact values. Its cache capacity is intentionally reduced to 32; the test remains bounded at 32 entries and reports 118 FIFO evictions while Browse, Read, Write, Subscribe, and cleanup all pass.
package/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 loliuy/opcua-client contributors
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # @tier0/opcua-client
2
+
3
+ High-scale OPC UA client nodes for Node-RED, backed by open62541 and designed for address spaces with thousands of points.
4
+
5
+ ## Nodes
6
+
7
+ - `tier0-opcua-connection`: shared client session and credentials.
8
+ - `tier0-opcua-browse`: recursive Browse plus a searchable, selectable address-space tree.
9
+ - `tier0-opcua-read`: scalar-compatible batch Read with a manual/imported point list.
10
+ - `tier0-opcua-write`: scalar-compatible batch Write with a manual/imported point list.
11
+ - `tier0-opcua-subscribe`: one Subscription with many MonitoredItems.
12
+
13
+ The OPC UA server remains a separate service. This package contains client and collection nodes only.
14
+
15
+ ## Point contract
16
+
17
+ Browse output can be wired directly into Read or Subscribe. In the Read and Write editors, manually added and imported points appear in one editable list. Import accepts JSON/CSV, merges by NodeId, and immediately shows the parsed rows. Both nodes also accept arrays, JSON strings/buffers, or CSV strings/buffers dynamically:
18
+
19
+ ```json
20
+ {
21
+ "payload": [
22
+ {
23
+ "nodeId": "ns=2;s=Line1.Temperature",
24
+ "alias": "line1.temperature",
25
+ "dataType": "Double",
26
+ "samplingInterval": 1000,
27
+ "queueSize": 10
28
+ }
29
+ ],
30
+ "opcua": {
31
+ "command": "replace"
32
+ }
33
+ }
34
+ ```
35
+
36
+ Subscription commands are `replace`, `add`, `remove`, and `clear`. Read and Write split large arrays into configurable service batches; they do not loop over scalar OPC UA requests.
37
+
38
+ Subscription notifications use a bounded native C++ queue and cross into JavaScript in batches. The default mode preserves all changes, batches up to 500 values, and caps pending changes at 10,000. Queue options are:
39
+
40
+ - `callbackBatchSize`: maximum changes per C++ to JavaScript callback.
41
+ - `callbackQueueSize`: hard native queue capacity.
42
+ - `queueMode`: `all` preserves pending changes; `latest` coalesces by MonitoredItem.
43
+ - `overflowPolicy`: `drop-oldest` or `drop-newest` when the hard capacity is reached.
44
+ - `outputMode`: `batch` sends one Node-RED message per native batch; `single` preserves one message per change compatibility.
45
+
46
+ Every batch includes queue diagnostics in `msg.opcua.queue`, including depth, capacity, maximum depth, enqueued, delivered, dropped, coalesced, batch sequence, and maximum queue lag. Queue overflow is also reported through the node status and warning log; it is never silent.
47
+
48
+ ## Adaptive batching and NodeId cache
49
+
50
+ Each connection reads the server's standard OperationLimits in one OPC UA Read request after the session is activated. Read, Write, monitored-item creation/removal, and Browse attribute reads automatically use the smaller of the configured batch size and the advertised server limit. A missing or zero limit means unknown/unlimited and preserves the configured batch size. Browse attribute requests are flattened before batching, so even a `MaxNodesPerRead` smaller than the five requested metadata attributes is respected exactly.
51
+
52
+ Parsed NodeIds are cached per connection in native code. The default capacity is 100,000 entries and is configurable as `NodeId cache size` on the Connection node. The cache uses bounded FIFO eviction, is cleared naturally on reconnect, and can be disabled with capacity `0`. Read and Write batch outputs expose live data through `msg.opcua.clientDiagnostics`; library users can call `client.getDiagnostics()`. Diagnostics include OperationLimits plus cache size, capacity, hits, misses, bypasses, and evictions.
53
+
54
+ ## Connection and subscription recovery
55
+
56
+ The native client reports secure-channel, session, connectivity-check, and subscription-inactivity transitions to a JavaScript connection supervisor. Recoverable channel interruptions keep the native client alive. Terminal failures replace the native client and retry with exponential backoff, 20% jitter, and the configured maximum interval. Operations fail fast with `OPCUA_RECONNECTING` while the connection is unavailable instead of building an unbounded request queue.
57
+
58
+ Subscribe nodes retain a desired point map independently from the live server subscription. After reconnection they serialize reconciliation, validate the old subscription, rebuild it when the server session was lost, and recreate missing MonitoredItems in service-sized batches. A recovery status message has `msg.opcua.operation = "subscription-status"`, `recovered`, `rebuilt`, `reason`, and `dataGapPossible`. `dataGapPossible` is true after a subscription rebuild because values may have changed while the server was unavailable.
59
+
60
+ Connection diagnostics also expose the current channel/session state and supervisor counters: client generation, reconnect attempts/successes, terminal replacements, and last recovery duration.
61
+
62
+ Read CSV point lists require a `nodeId` header. Write CSV uses `nodeId,value,dataType`; `dataType` is optional when the Write node supplies a default. Other supported descriptor columns include `alias`, `samplingInterval`, `queueSize`, `deadbandType`, and `deadbandValue`.
63
+
64
+ ```csv
65
+ nodeId,value,dataType
66
+ ns=2;s=Line1.Speed,1200,Double
67
+ ns=2;s=Line1.Enabled,true,Boolean
68
+ ```
69
+
70
+ An incoming point list takes priority over the point list saved in the node. Existing flows that stored a single static NodeId are migrated into the visible list when their editor is opened. A migrated Write item keeps using its incoming `msg.payload`; imported Write rows normally carry `nodeId`, `value`, and optional `dataType`.
71
+
72
+ ## Browse tree selection
73
+
74
+ The Browse editor loads the address space through the deployed Connection node. Object nodes act as folders and Variable nodes are selectable outputs. Checking a parent selects every variable below it; clearing an individual child changes every affected ancestor to the half-selected state. The editor shows the selected/total variable count and provides branch expansion, name/path/NodeId filtering, Select all, and Clear actions.
75
+
76
+ The selected Variable descriptors are saved with the Browse node. Each input message outputs the full saved selection so it can be wired directly into Read or Subscribe. Send `msg.opcua.liveBrowse = true` when a flow needs to ignore the saved selection and perform a new live Browse.
77
+
78
+ Node editors show only the standard settings by default. Connection security/timeouts/cache, Browse limits/paging, Read/Write batching/output, and Subscribe queue/deadband tuning remain available under a closed **Advanced settings** section. Existing flows retain their stored advanced values.
79
+
80
+ ## Build
81
+
82
+ Linux build prerequisites are a C/C++ compiler, CMake, Python, and Git.
83
+
84
+ ```bash
85
+ npm install
86
+ npm test
87
+ ```
88
+
89
+ `npm install` builds the pinned open62541 release first and then compiles the N-API addon. To rebuild only the native layer after changing C++ code, run `npm run build`.
90
+
91
+ The open62541 build is static and position-independent so the resulting N-API addon does not need a runtime open62541 shared library.
92
+
93
+ Security mode `None` is supported in the first implementation slice. Sign and SignAndEncrypt are visible in the connection model but require an encryption-enabled open62541 build and certificate trust-store integration before production use.
94
+
95
+ ## Native integration test
96
+
97
+ The fixture requires Python package `opcua==0.98.13`:
98
+
99
+ ```bash
100
+ python3 -m venv .build/test-venv
101
+ .build/test-venv/bin/pip install opcua==0.98.13
102
+ PATH="$PWD/.build/test-venv/bin:$PATH" npm run test:native
103
+ ```
104
+
105
+ ## Stress test
106
+
107
+ The stress test starts a real writable OPC UA server, browses the complete address space, performs repeated batched reads and writes, creates one Subscription containing all MonitoredItems, verifies change delivery for every point, and reports latency, throughput, memory, CPU, and event-loop delay.
108
+
109
+ ```bash
110
+ OPCUA_STRESS_POINTS=5000 \
111
+ OPCUA_STRESS_READ_ROUNDS=5 \
112
+ OPCUA_STRESS_WRITE_ROUNDS=2 \
113
+ npm run test:stress
114
+ ```
115
+
116
+ The point count, read/write rounds, service batch size, port, and notification timeout are configurable with the `OPCUA_STRESS_*` environment variables used in `test/stress.js`.
117
+
118
+ For a strict Node-RED runtime test, `test:nodered` deploys a temporary isolated Flow into a running editor, triggers the real nodes through HTTP, records Node-RED process memory/CPU, and removes the Flow afterward while verifying that the original flow set is unchanged:
119
+
120
+ ```bash
121
+ OPCUA_NODERED_POINTS=5000 npm run test:nodered
122
+ ```
123
+
124
+ To inject a real server outage after subscription creation, restart the fixture, require automatic subscription reconstruction, and then verify every point converges through the deployed Node-RED nodes:
125
+
126
+ ```bash
127
+ OPCUA_NODERED_POINTS=1000 npm run test:nodered:reconnect
128
+ ```
129
+
130
+ See [BENCHMARK.md](BENCHMARK.md) for the recorded 1,000, 5,000, sustained 5,000, and 10,000 point baselines.
131
+
132
+ ## Scale defaults
133
+
134
+ - Browse limit: 10,000 returned points.
135
+ - Attribute read batch: 200 points.
136
+ - Read/Write service batch: 500 points.
137
+ - MonitoredItem creation batch: 500 points.
138
+ - Subscription callback queue: 10,000 pending changes.
139
+ - Parsed NodeId cache: 100,000 entries per connection.
140
+
141
+ Configured service batches are automatically clamped to the server's advertised OperationLimits.
package/binding.gyp ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "targets": [
3
+ {
4
+ "target_name": "opcua_open62541",
5
+ "sources": ["src/addon.cc"],
6
+ "include_dirs": [
7
+ "<!@(node -p \"require('node-addon-api').include\")",
8
+ "<!(node -e \"const path=require('path');console.log(process.env.OPEN62541_PREFIX||path.join(process.cwd(),'.build','open62541'))\")/include"
9
+ ],
10
+ "libraries": [
11
+ "-L<!(node -e \"const path=require('path');console.log(process.env.OPEN62541_PREFIX||path.join(process.cwd(),'.build','open62541'))\")/lib",
12
+ "-lopen62541"
13
+ ],
14
+ "dependencies": ["<!(node -p \"require('node-addon-api').gyp\")"],
15
+ "defines": ["NAPI_CPP_EXCEPTIONS"],
16
+ "cflags!": ["-fno-exceptions"],
17
+ "cflags_cc!": ["-fno-exceptions"],
18
+ "cflags_cc": ["-std=c++20"],
19
+ "conditions": [
20
+ ["OS=='linux'", {"libraries": ["-lpthread", "-lm"]}]
21
+ ]
22
+ }
23
+ ]
24
+ }
package/index.js ADDED
@@ -0,0 +1,5 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ points: require('./lib/points'),
5
+ };
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ function cleanBrowseNode(item) {
4
+ const node = { ...(item || {}) };
5
+ delete node.children;
6
+ return node;
7
+ }
8
+
9
+ function buildBrowseTree(items, rootNodeId = 'ns=0;i=85') {
10
+ if (!Array.isArray(items)) throw new Error('Browse result must be an array');
11
+ const root = {
12
+ nodeId: rootNodeId,
13
+ parentNodeId: '',
14
+ browseName: rootNodeId,
15
+ displayName: rootNodeId,
16
+ browsePath: '',
17
+ nodeClass: 'Root',
18
+ children: [],
19
+ };
20
+ const byId = new Map([[rootNodeId, root]]);
21
+
22
+ for (const item of items) {
23
+ if (!item?.nodeId || item.nodeId === rootNodeId || byId.has(item.nodeId)) continue;
24
+ byId.set(item.nodeId, { ...cleanBrowseNode(item), children: [] });
25
+ }
26
+ for (const node of byId.values()) {
27
+ if (node === root) continue;
28
+ const parent = byId.get(node.parentNodeId);
29
+ (parent && parent !== node ? parent : root).children.push(node);
30
+ }
31
+ return root;
32
+ }
33
+
34
+ function flattenBrowseTree(root) {
35
+ const output = [];
36
+ const visit = (node) => {
37
+ if (node.nodeClass !== 'Root') output.push(cleanBrowseNode(node));
38
+ for (const child of node.children || []) visit(child);
39
+ };
40
+ visit(root);
41
+ return output;
42
+ }
43
+
44
+ function selectedVariables(items) {
45
+ if (!Array.isArray(items)) return [];
46
+ const selected = new Map();
47
+ for (const item of items) {
48
+ if (item?.nodeId && item.nodeClass === 'Variable') {
49
+ selected.set(item.nodeId, cleanBrowseNode(item));
50
+ }
51
+ }
52
+ return [...selected.values()];
53
+ }
54
+
55
+ module.exports = { buildBrowseTree, cleanBrowseNode, flattenBrowseTree, selectedVariables };
package/lib/client.js ADDED
@@ -0,0 +1,321 @@
1
+ 'use strict';
2
+
3
+ const { EventEmitter } = require('events');
4
+ const { chunks } = require('./points');
5
+ const { loadAddon } = require('./native');
6
+
7
+ function normalizeNotificationBatch(notification) {
8
+ if (notification && Array.isArray(notification.changes)) {
9
+ if (!notification.diagnostics) notification.diagnostics = {};
10
+ return notification;
11
+ }
12
+ return {
13
+ changes: notification == null ? [] : [notification],
14
+ diagnostics: {},
15
+ };
16
+ }
17
+
18
+ function effectiveBatchSize(requested, serverLimit) {
19
+ const configured = Number.isFinite(Number(requested)) && Number(requested) > 0
20
+ ? Math.floor(Number(requested))
21
+ : 500;
22
+ const limit = Number(serverLimit);
23
+ return Number.isFinite(limit) && limit > 0
24
+ ? Math.min(configured, Math.floor(limit))
25
+ : configured;
26
+ }
27
+
28
+ class OpcUaClient extends EventEmitter {
29
+ constructor(options) {
30
+ super();
31
+ this.options = { ...options };
32
+ this.handle = null;
33
+ this.connecting = null;
34
+ this.closed = false;
35
+ this.connectionState = 'idle';
36
+ this.connectionToken = 0;
37
+ this.reconnectTimer = null;
38
+ this.reconnectAttempt = 0;
39
+ this.supervisorDiagnostics = {
40
+ clientGeneration: 0,
41
+ reconnectAttempts: 0,
42
+ reconnectSuccesses: 0,
43
+ terminalReplacements: 0,
44
+ reconnectStartedAt: null,
45
+ lastReconnectDurationMs: 0,
46
+ };
47
+ this.lastState = {
48
+ state: 'idle',
49
+ connected: false,
50
+ statusCode: 'Good',
51
+ };
52
+ this.diagnostics = {
53
+ operationLimits: {},
54
+ nodeIdCache: {},
55
+ connection: {},
56
+ };
57
+ }
58
+
59
+ async connect() {
60
+ if (this.closed) throw new Error('OPC UA client is closed');
61
+ if (this.handle != null) {
62
+ if (this.connectionState === 'connected') return this.handle;
63
+ const error = new Error(`OPC UA client is ${this.connectionState}`);
64
+ error.code = 'OPCUA_RECONNECTING';
65
+ throw error;
66
+ }
67
+ if (this.connecting) return this.connecting;
68
+
69
+ this.connecting = this.#connect();
70
+ try {
71
+ return await this.connecting;
72
+ } finally {
73
+ this.connecting = null;
74
+ if (!this.closed && this.handle == null && this.connectionState === 'failed') {
75
+ this.#scheduleReconnect();
76
+ }
77
+ }
78
+ }
79
+
80
+ async #connect() {
81
+ const addon = loadAddon();
82
+ const token = ++this.connectionToken;
83
+ this.connectionState = 'connecting';
84
+ let handle;
85
+ try {
86
+ handle = await addon.connectAsync(
87
+ this.options,
88
+ (state) => this.#onNativeState(token, state)
89
+ );
90
+ } catch (error) {
91
+ if (token === this.connectionToken && !this.closed) {
92
+ this.connectionState = 'failed';
93
+ this.lastState = {
94
+ state: 'failed', connected: false, terminal: true,
95
+ statusCode: error.message, reason: 'connect-failed',
96
+ };
97
+ this.emit('state', this.lastState);
98
+ this.emit('fault', error);
99
+ this.#scheduleReconnect();
100
+ }
101
+ throw error;
102
+ }
103
+ if (this.closed || token !== this.connectionToken) {
104
+ await addon.disconnectAsync(handle).catch(() => {});
105
+ throw new Error('OPC UA connection attempt was superseded');
106
+ }
107
+ this.handle = handle;
108
+ this.supervisorDiagnostics.clientGeneration = token;
109
+ if (this.supervisorDiagnostics.reconnectStartedAt != null) {
110
+ this.supervisorDiagnostics.reconnectSuccesses += 1;
111
+ this.supervisorDiagnostics.lastReconnectDurationMs =
112
+ Date.now() - this.supervisorDiagnostics.reconnectStartedAt;
113
+ this.supervisorDiagnostics.reconnectStartedAt = null;
114
+ }
115
+ this.#refreshDiagnostics();
116
+ if (this.connectionState !== 'connected') {
117
+ this.connectionState = 'connected';
118
+ this.lastState = {
119
+ state: 'connected', connected: true, terminal: false,
120
+ statusCode: 'Good', reason: 'connect-complete',
121
+ sessionGeneration: this.diagnostics.connection?.sessionGeneration || 1,
122
+ };
123
+ this.emit('state', this.lastState);
124
+ }
125
+ this.reconnectAttempt = 0;
126
+ return handle;
127
+ }
128
+
129
+ async disconnect() {
130
+ this.closed = true;
131
+ this.connectionState = 'closing';
132
+ this.connectionToken += 1;
133
+ if (this.reconnectTimer) {
134
+ clearTimeout(this.reconnectTimer);
135
+ this.reconnectTimer = null;
136
+ }
137
+ const connecting = this.connecting;
138
+ if (connecting) {
139
+ try { await connecting; } catch {}
140
+ }
141
+ const handle = this.handle;
142
+ this.handle = null;
143
+ if (handle != null) {
144
+ try {
145
+ await loadAddon().disconnectAsync(handle);
146
+ } finally {
147
+ this.connectionState = 'closed';
148
+ }
149
+ }
150
+ this.lastState = { state: 'closed', connected: false, statusCode: 'Good' };
151
+ this.emit('state', this.lastState);
152
+ }
153
+
154
+ async browse(options) {
155
+ return this.#invoke('browseAsync', options);
156
+ }
157
+
158
+ async readMany(points, batchSize = 500) {
159
+ const results = [];
160
+ await this.connect();
161
+ const effective = effectiveBatchSize(
162
+ batchSize, this.diagnostics.operationLimits?.maxNodesPerRead
163
+ );
164
+ for (const batch of chunks(points, effective)) {
165
+ results.push(...await this.#invoke('readManyAsync', batch));
166
+ }
167
+ return results;
168
+ }
169
+
170
+ async writeMany(points, batchSize = 500) {
171
+ const results = [];
172
+ await this.connect();
173
+ const effective = effectiveBatchSize(
174
+ batchSize, this.diagnostics.operationLimits?.maxNodesPerWrite
175
+ );
176
+ for (const batch of chunks(points, effective)) {
177
+ results.push(...await this.#invoke('writeManyAsync', batch));
178
+ }
179
+ return results;
180
+ }
181
+
182
+ async createSubscription(options, callback) {
183
+ return this.#invoke('createSubscriptionAsync', options, (notification) => {
184
+ callback(normalizeNotificationBatch(notification));
185
+ });
186
+ }
187
+
188
+ async addMonitoredItems(subscriptionId, points, options, batchSize = 500) {
189
+ const results = [];
190
+ await this.connect();
191
+ const effective = effectiveBatchSize(
192
+ batchSize, this.diagnostics.operationLimits?.maxMonitoredItemsPerCall
193
+ );
194
+ for (const batch of chunks(points, effective)) {
195
+ results.push(...await this.#invoke(
196
+ 'addMonitoredItemsAsync', subscriptionId, batch, options
197
+ ));
198
+ }
199
+ return results;
200
+ }
201
+
202
+ async removeMonitoredItems(subscriptionId, monitoredItemIds, batchSize = 500) {
203
+ const results = [];
204
+ await this.connect();
205
+ const effective = effectiveBatchSize(
206
+ batchSize, this.diagnostics.operationLimits?.maxMonitoredItemsPerCall
207
+ );
208
+ for (const batch of chunks(monitoredItemIds, effective)) {
209
+ results.push(...await this.#invoke('removeMonitoredItemsAsync', subscriptionId, batch));
210
+ }
211
+ return results;
212
+ }
213
+
214
+ async deleteSubscription(subscriptionId) {
215
+ return this.#invoke('deleteSubscriptionAsync', subscriptionId);
216
+ }
217
+
218
+ async validateSubscription(subscriptionId) {
219
+ return this.#invoke('validateSubscriptionAsync', subscriptionId);
220
+ }
221
+
222
+ getState() {
223
+ return { ...this.lastState };
224
+ }
225
+
226
+ getDiagnostics() {
227
+ this.#refreshDiagnostics();
228
+ return this.diagnostics;
229
+ }
230
+
231
+ #refreshDiagnostics() {
232
+ if (this.handle == null) return;
233
+ const addon = loadAddon();
234
+ if (typeof addon.getClientDiagnostics !== 'function') return;
235
+ try {
236
+ const nativeDiagnostics = addon.getClientDiagnostics(this.handle) || {};
237
+ this.diagnostics = {
238
+ ...nativeDiagnostics,
239
+ connection: {
240
+ ...(nativeDiagnostics.connection || {}),
241
+ supervisor: { ...this.supervisorDiagnostics },
242
+ },
243
+ };
244
+ } catch {
245
+ // The connection may have closed between reading the handle and querying diagnostics.
246
+ }
247
+ }
248
+
249
+ #onNativeState(token, state) {
250
+ if (this.closed || token !== this.connectionToken || !state) return;
251
+ if (state.state === 'subscription-inactive') {
252
+ this.emit('state', { ...state, clientGeneration: token });
253
+ return;
254
+ }
255
+ this.connectionState = state.state || (state.connected ? 'connected' : 'reconnecting');
256
+ this.lastState = { ...state, clientGeneration: token };
257
+ this.emit('state', this.lastState);
258
+ if (state.connected) {
259
+ this.reconnectAttempt = 0;
260
+ this.#refreshDiagnostics();
261
+ } else if (state.terminal) {
262
+ void this.#replaceTerminalClient(token, state);
263
+ }
264
+ }
265
+
266
+ async #replaceTerminalClient(token, state) {
267
+ if (this.closed || token !== this.connectionToken) return;
268
+ this.connectionToken += 1;
269
+ this.supervisorDiagnostics.terminalReplacements += 1;
270
+ const handle = this.handle;
271
+ this.handle = null;
272
+ this.connectionState = 'failed';
273
+ if (handle != null) {
274
+ try { await loadAddon().disconnectAsync(handle); } catch {}
275
+ }
276
+ if (!this.closed) {
277
+ const error = new Error(`OPC UA connection failed: ${state.statusCode || 'Unknown'}`);
278
+ error.code = 'OPCUA_CONNECTION_FAILED';
279
+ this.emit('fault', error);
280
+ this.#scheduleReconnect();
281
+ }
282
+ }
283
+
284
+ #scheduleReconnect() {
285
+ if (this.closed || this.reconnectTimer || this.connecting) return;
286
+ const base = Math.max(100, Number(this.options.reconnectInterval) || 5000);
287
+ const maximum = Math.max(base, Number(this.options.maxReconnectInterval) || 30000);
288
+ const exponential = Math.min(maximum, base * (2 ** this.reconnectAttempt));
289
+ const jitter = 0.8 + Math.random() * 0.4;
290
+ const delay = Math.max(100, Math.round(exponential * jitter));
291
+ this.reconnectAttempt += 1;
292
+ this.supervisorDiagnostics.reconnectAttempts += 1;
293
+ if (this.supervisorDiagnostics.reconnectStartedAt == null) {
294
+ this.supervisorDiagnostics.reconnectStartedAt = Date.now();
295
+ }
296
+ this.connectionState = 'backoff';
297
+ this.lastState = {
298
+ state: 'backoff', connected: false, recoverable: true,
299
+ reason: 'new-client', attempt: this.reconnectAttempt, delay,
300
+ };
301
+ this.emit('state', this.lastState);
302
+ this.reconnectTimer = setTimeout(() => {
303
+ this.reconnectTimer = null;
304
+ this.connect().catch(() => {});
305
+ }, delay);
306
+ }
307
+
308
+ async #invoke(method, ...args) {
309
+ const handle = await this.connect();
310
+ try {
311
+ return await loadAddon()[method](handle, ...args);
312
+ } catch (error) {
313
+ if (/disconnect|connection|session|channel/i.test(error.message)) {
314
+ this.emit('fault', error);
315
+ }
316
+ throw error;
317
+ }
318
+ }
319
+ }
320
+
321
+ module.exports = { OpcUaClient, effectiveBatchSize, normalizeNotificationBatch };