@tier0/opcua-client 0.1.0 → 0.2.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.
Files changed (45) hide show
  1. package/BENCHMARK.md +53 -2
  2. package/README.md +131 -14
  3. package/THIRD_PARTY_NOTICES.md +20 -0
  4. package/binding.gyp +1 -1
  5. package/lib/client.js +48 -7
  6. package/lib/native.js +25 -10
  7. package/lib/points.js +187 -21
  8. package/lib/prebuild.js +42 -0
  9. package/lib/security.js +96 -0
  10. package/node_modules/node-addon-api/LICENSE.md +9 -0
  11. package/node_modules/node-addon-api/README.md +95 -0
  12. package/node_modules/node-addon-api/common.gypi +21 -0
  13. package/node_modules/node-addon-api/except.gypi +25 -0
  14. package/node_modules/node-addon-api/index.js +14 -0
  15. package/node_modules/node-addon-api/napi-inl.deprecated.h +186 -0
  16. package/node_modules/node-addon-api/napi-inl.h +7244 -0
  17. package/node_modules/node-addon-api/napi.h +3420 -0
  18. package/node_modules/node-addon-api/node_addon_api.gyp +42 -0
  19. package/node_modules/node-addon-api/node_api.gyp +9 -0
  20. package/node_modules/node-addon-api/noexcept.gypi +26 -0
  21. package/node_modules/node-addon-api/nothing.c +0 -0
  22. package/node_modules/node-addon-api/package-support.json +21 -0
  23. package/node_modules/node-addon-api/package.json +481 -0
  24. package/node_modules/node-addon-api/tools/README.md +73 -0
  25. package/node_modules/node-addon-api/tools/check-napi.js +99 -0
  26. package/node_modules/node-addon-api/tools/clang-format.js +71 -0
  27. package/node_modules/node-addon-api/tools/conversion.js +301 -0
  28. package/nodes/connection.html +165 -6
  29. package/nodes/connection.js +99 -20
  30. package/nodes/read.html +3 -0
  31. package/nodes/read.js +3 -1
  32. package/nodes/subscribe.html +9 -3
  33. package/nodes/subscribe.js +330 -99
  34. package/nodes/write.html +3 -0
  35. package/package.json +15 -4
  36. package/prebuilds/linux-x64-musl/opcua_open62541.node +0 -0
  37. package/scripts/build-offline-prebuild.sh +15 -0
  38. package/scripts/build-open62541.sh +14 -4
  39. package/scripts/build-test-server.sh +23 -0
  40. package/scripts/install.js +28 -0
  41. package/scripts/verify-offline-package.js +20 -0
  42. package/scripts/verify-runtime.js +20 -0
  43. package/src/addon.cc +707 -119
  44. package/vendor/open62541-LICENSE +373 -0
  45. package/vendor/open62541-v1.4.16.tar.gz +0 -0
package/BENCHMARK.md CHANGED
@@ -69,7 +69,7 @@ The native queue integration test also intentionally uses capacity 5 and batch s
69
69
 
70
70
  ## OperationLimits and NodeId cache optimization
71
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.
72
+ The P1 optimization originally read four standard OperationLimits in one request and added a bounded per-connection native parsed-NodeId cache. It now also reads the optional `MaxMonitoredItemsPerSubscription` server capability. 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
73
 
74
74
  The optimized Node-RED Flow results were:
75
75
 
@@ -80,4 +80,55 @@ The optimized Node-RED Flow results were:
80
80
 
81
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
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.
83
+ The native integration fixture advertises the four OperationLimits as 7 and leaves `MaxMonitoredItemsPerSubscription` unspecified, which the client reports as 0 before applying its configured fallback. 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.
84
+
85
+ ## Multi-Subscription sharding and timed aggregation
86
+
87
+ The 2026-08-06 real Node-RED smoke run forced the 100-point set into four Subscriptions by setting `maxItemsPerSubscription=25`. Browse, batch Read, batch Write, subscription creation, and the selectable Browse output all ran through a deployed Flow and a real OPC UA fixture.
88
+
89
+ - Subscription creation: 72 ms for 100 MonitoredItems across 4 Subscriptions.
90
+ - Two complete write rounds: 200 notifications delivered, 0 dropped, final values converged for 100/100 points.
91
+ - Combined native queues: maximum depth 200 and capacity 200,000 across the four Subscriptions.
92
+ - Node-RED process: 134.9 MB peak RSS and 130 ms sampled CPU for the complete smoke run.
93
+
94
+ The outage run stopped and restarted the real fixture after subscription creation. The Subscribe node invalidated the old shard set and recovered exactly four Subscriptions containing 100/100 desired points. Subsequent writes again converged 100/100 final values with zero queue drops. Rebuild notifications can include current server values, so final per-point convergence and the recovered Subscription/MonitoredItem counts are the correctness gates rather than an exact raw notification count.
95
+
96
+ The scale acceptance then used 5,000 points with `maxItemsPerSubscription=1000`. The deployed Node-RED Flow created exactly five Subscriptions in 478 ms, browsed 5,000 variables in 2,047 ms, read them in 321 ms, and completed each 5,000-point write round in 324 ms. The native queues delivered all 15,000 notifications with zero drops; final values converged for 5,000/5,000 points. Peak Node-RED RSS was 150.0 MB.
97
+
98
+ The matching outage run rebuilt exactly five Subscriptions containing 5,000/5,000 desired points after the server restarted. It again delivered 15,000/15,000 native notifications with zero drops and converged every final value. The owner command queue completed 42/42 commands with maximum depth 4, zero rejection, and zero timeout; peak Node-RED RSS was 151.6 MB.
99
+
100
+ ## Single-owner native command queue
101
+
102
+ The 2026-08-06 concurrency run used a real 3,000-point OPC UA fixture and submitted four Browse, four 200-point Read, and four 200-point Write operations concurrently. Including Connect, the native owner thread completed 13/13 commands with zero rejection and a maximum command queue depth of 4. A 1 ms JavaScript timer advanced 4,604 times during the run, confirming that the Node.js event loop remained responsive while the native owner serialized OPC UA work.
103
+
104
+ The overload run reduced command queue capacity to 1. Queue depth never exceeded 1; seven commands were rejected immediately as full and one queued command was safely removed after its one-second queue timeout. The diagnostic counters reported two completed commands, eight total rejections, and one timeout. Native notification queue, reconnect, and real Node-RED four-Subscription recovery tests continued to pass after the ownership change.
105
+
106
+ ## 20,000-point sustained Node-RED pressure run
107
+
108
+ The 2026-08-06 capacity run deployed the real nodes into Node-RED with 20,000 writable OPC UA variables, three complete Read rounds, four 5,000-item Subscriptions, and five complete Write rounds.
109
+
110
+ | Metric | Result |
111
+ | --- | ---: |
112
+ | Browse 20,000 variables | 7,948 ms |
113
+ | Read P50 / P95 | 1,236 / 1,559 ms |
114
+ | Write P50 / P95 | 1,493 / 1,917 ms |
115
+ | Create 20,000 MonitoredItems / 4 Subscriptions | 1,639 ms |
116
+ | Native notifications | 120,000 / 120,000 delivered |
117
+ | Final value convergence | 20,000 / 20,000 points |
118
+ | Notification drops | 0 |
119
+ | Maximum notification queue depth | 7,978 / 320,000 capacity |
120
+ | Maximum native callback lag | 5.24 ms |
121
+ | Owner commands | 367 / 367 completed, 0 rejected, 0 timed out |
122
+ | Node-RED peak RSS | 342.9 MB |
123
+ | Node-RED sampled CPU | 7,000 ms |
124
+ | Complete run | 34.3 s |
125
+
126
+ The collector observed 100,500 changes after its subscription-status gate, while native diagnostics recorded all 120,000 initial and write notifications. As in the smaller runs, notifications may arrive before the collector initializes its counter; zero native drops and convergence of every point to the final write value are the correctness gates. Phase sampling and an isolated external-writer run were subsequently used to locate and reduce the transient memory peak.
127
+
128
+ ## 20,000-point memory isolation and CSV Write optimization
129
+
130
+ The same deployed Flow was tested with a separate native client generating the five write rounds. Node-RED still browsed 20,000 variables, rendered the 20,040-node editor tree, read all points, created four 5,000-item Subscriptions, collected notifications, and verified final convergence. Moving only write-payload construction outside Node-RED reduced its peak RSS to 170.27 MB. The phase peak during Subscribe creation was 166.47 MB and the peak during 100,000 external writes plus notification handling was 170.27 MB. All 120,000 native notifications were delivered with zero drops and 20,000/20,000 final convergence. This isolates the collection path from the CSV Write path and shows that subscription setup and notification delivery were not responsible for the earlier 342-362 MB peaks.
131
+
132
+ Write CSV parsing was then changed from multiple whole-payload arrays and object copies to a single line scan that constructs compact final write descriptors. Two mixed Node-RED runs, each producing CSV and executing all 100,000 writes inside the Flow, recorded stable peaks of 226.73 MB and 226.06 MB. The immediately preceding fresh-process repeat peaked at 362.29 MB, so the repeatable peak reduction was approximately 37.6%. The optimized repeat recorded Write P50/P95 of 1,410/1,769 ms, delivered 120,000/120,000 native notifications with zero drops, and converged 20,000/20,000 final values. These are process-RSS engineering measurements; allocator and garbage-collector behavior still require production baselines on the target Node-RED image.
133
+
134
+ The 20,000-point outage acceptance stopped and restarted the real OPC UA fixture after the initial four Subscriptions were created. The Subscribe node rebuilt exactly four Subscriptions with 20,000/20,000 MonitoredItems and zero failures. Two subsequent complete write rounds plus the rebuilt initial values delivered 60,000/60,000 native notifications with zero drops and final convergence for all 20,000 points. Recovery-phase RSS peaked at 175.04 MB and the complete run at 184.5 MB. The recovery status explicitly reported `rebuilt=true` and `dataGapPossible=true` because a server-side subscription rebuild cannot guarantee changes that occurred during the outage.
package/README.md CHANGED
@@ -6,9 +6,9 @@ High-scale OPC UA client nodes for Node-RED, backed by open62541 and designed fo
6
6
 
7
7
  - `tier0-opcua-connection`: shared client session and credentials.
8
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.
9
+ - `tier0-opcua-read`: scalar/array batch Read with optional IndexRange and a manual/imported point list.
10
+ - `tier0-opcua-write`: scalar/array/matrix batch Write with optional IndexRange and a manual/imported point list.
11
+ - `tier0-opcua-subscribe`: one or more automatically sharded Subscriptions with many MonitoredItems.
12
12
 
13
13
  The OPC UA server remains a separate service. This package contains client and collection nodes only.
14
14
 
@@ -35,13 +35,18 @@ Browse output can be wired directly into Read or Subscribe. In the Read and Writ
35
35
 
36
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
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:
38
+ Subscription notifications use a bounded native C++ queue and cross into JavaScript in batches. In Node-RED batch mode, changes from every server Subscription are merged and emitted when 500 values are ready or the default 50 ms flush window expires. Queue options are:
39
39
 
40
- - `callbackBatchSize`: maximum changes per C++ to JavaScript callback.
40
+ - `callbackBatchSize`: maximum changes per native callback and Node-RED output message.
41
+ - `callbackFlushIntervalMs`: maximum wait for a partially filled Node-RED output batch (default 50 ms).
41
42
  - `callbackQueueSize`: hard native queue capacity.
42
43
  - `queueMode`: `all` preserves pending changes; `latest` coalesces by MonitoredItem.
43
44
  - `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
+ - `outputMode`: `batch` aggregates changes across Subscriptions; `single` preserves one message per change compatibility.
46
+
47
+ `timestampMode` defaults to `source`, avoiding an unnecessary server timestamp on every high-rate notification. Advanced settings can request `both`, `server`, or `neither`. Notification outputs contain the changing value/status/timestamp plus useful custom point metadata such as `alias`; monitoring configuration that is constant for the lifetime of the item is not repeated on every change.
48
+
49
+ Large point sets are automatically split across server Subscriptions. `maxItemsPerSubscription` defaults to 5,000 and acts as the fallback safety limit. When the server advertises `MaxMonitoredItemsPerSubscription`, the node uses the smaller limit. Status messages expose `subscriptionCount`, `subscriptionIds`, `desired`, `monitored`, and `failed`.
45
50
 
46
51
  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
52
 
@@ -51,15 +56,23 @@ Each connection reads the server's standard OperationLimits in one OPC UA Read r
51
56
 
52
57
  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
58
 
59
+ ## Native client ownership and command queue
60
+
61
+ Each Connection owns one native C++ thread. That thread creates and closes the `UA_Client`, executes Connect, Browse, Read, Write, and Subscription commands, and runs `UA_Client_run_iterate`. Node.js async workers only submit commands and wait for their results, so concurrent Flow messages never access one open62541 client from multiple threads.
62
+
63
+ The native command queue defaults to 256 pending commands and is configurable as `Command queue` under advanced Connection settings. Queue capacity is hard: overload is rejected with an explicit error. A command that remains queued longer than the configured OPC UA request timeout is removed safely; an already-running service call is allowed to finish under open62541's request timeout. Closing a Connection stops accepting work, rejects pending commands, and performs OPC UA cleanup on the owner thread.
64
+
65
+ `client.getDiagnostics().commandQueue` reports `depth`, `capacity`, `maxDepth`, `active`, `enqueued`, `completed`, `rejected`, and `timedOut`.
66
+
54
67
  ## Connection and subscription recovery
55
68
 
56
69
  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
70
 
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.
71
+ Subscribe nodes retain a desired point map independently from live server Subscriptions. After reconnection they serialize reconciliation, validate every old Subscription, rebuild the complete shard set when the server session was lost, and recreate each MonitoredItem once 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
72
 
60
73
  Connection diagnostics also expose the current channel/session state and supervisor counters: client generation, reconnect attempts/successes, terminal replacements, and last recovery duration.
61
74
 
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`.
75
+ Read CSV point lists require a `nodeId` header. Write CSV uses `nodeId,value,dataType`; `dataType` is optional when the Write node supplies a default. Large Write CSV payloads are scanned once directly into compact native-write descriptors, avoiding several full-size intermediate point lists. Duplicate NodeIds retain the last row. Other supported descriptor columns include `alias`, `samplingInterval`, `queueSize`, `deadbandType`, and `deadbandValue`.
63
76
 
64
77
  ```csv
65
78
  nodeId,value,dataType
@@ -67,6 +80,29 @@ ns=2;s=Line1.Speed,1200,Double
67
80
  ns=2;s=Line1.Enabled,true,Boolean
68
81
  ```
69
82
 
83
+ ## Arrays and IndexRange
84
+
85
+ Read and Write descriptors accept the standard OPC UA `indexRange` field. The
86
+ legacy `range` spelling is normalized to `indexRange`. One-dimensional examples
87
+ are `0` and `2:5`; matrix ranges use a comma, for example `0:1,2:3`. Invalid or
88
+ descending ranges are rejected before an OPC UA service call.
89
+
90
+ Write values may be scalar JavaScript values, arrays, or rectangular nested
91
+ arrays. Nested arrays are converted to OPC UA matrix dimensions; ragged or empty
92
+ arrays are rejected. A direct array payload is treated as the value when the
93
+ message or Write node identifies one target NodeId. A configured single
94
+ `useInput` point behaves the same way. Set `msg.opcua.pointList = true` only when
95
+ an array payload must be forced to mean a dynamic list of point descriptors.
96
+
97
+ ```json
98
+ {
99
+ "topic": "ns=2;s=Recipe.Steps",
100
+ "indexRange": "4:6",
101
+ "dataType": "Double",
102
+ "payload": [12.5, 13.0, 13.5]
103
+ }
104
+ ```
105
+
70
106
  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
107
 
72
108
  ## Browse tree selection
@@ -77,20 +113,73 @@ The selected Variable descriptors are saved with the Browse node. Each input mes
77
113
 
78
114
  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
115
 
80
- ## Build
116
+ ## Secure connections
117
+
118
+ Connection nodes support `None/None` and `Sign` or `SignAndEncrypt` with
119
+ `Basic256Sha256`. Secure connections require a client certificate, its private
120
+ key, and at least one trusted Server or CA certificate. Trust is strict: an
121
+ empty trust list is rejected instead of silently accepting every Server
122
+ certificate. Optional CRL files can be supplied as a newline-separated list.
123
+ Password-protected PEM private keys use the separate Node-RED credential field,
124
+ so the password is not stored in the normal Flow configuration.
125
+
126
+ The client Application URI can be configured when the URI embedded in the
127
+ client certificate differs from the open62541 default. Username authentication
128
+ uses the selected secure endpoint and its advertised UserTokenPolicy.
129
+
130
+ The Connection editor can call `GetEndpoints`, filters the response to the
131
+ implemented policies, and selects an endpoint compatible with Anonymous or
132
+ Username authentication. Certificate, private-key, trust-list, and CRL entries
133
+ are filesystem paths visible to the Node-RED runtime.
134
+
135
+ ## Offline installation
136
+
137
+ The published package contains a prebuilt `linux-x64-musl` native addon for the
138
+ Node-RED Alpine image, the pinned open62541 v1.4.16 source archive, and its
139
+ `node-addon-api` build dependency. The same Node-API binary is verified on Node
140
+ 20.19.6 and Node 22.22.2. Installing the tarball on the supported target does
141
+ not contact npm or GitHub and does not invoke a compiler:
81
142
 
82
- Linux build prerequisites are a C/C++ compiler, CMake, Python, and Git.
143
+ ```bash
144
+ npm install ./tier0-opcua-client-0.1.0.tgz --offline
145
+ ```
146
+
147
+ The loader uses the bundled native addon directly. Set
148
+ `OPCUA_CLIENT_FORCE_BUILD=1` only to force a source rebuild. Unsupported
149
+ platforms fall back to the bundled open62541 source and therefore still require
150
+ a local C/C++ toolchain, CMake, Python, Node headers, and OpenSSL development
151
+ files. No network is needed for open62541 itself.
152
+
153
+ Create the supported prebuild and package on Linux with:
154
+
155
+ ```bash
156
+ npm ci --ignore-scripts
157
+ npm run build:offline-prebuild -- --nodedir=/usr/local
158
+ npm pack
159
+ ```
160
+
161
+ See `THIRD_PARTY_NOTICES.md` for the bundled open62541 and node-addon-api
162
+ licenses.
163
+
164
+ ## Source build
165
+
166
+ Linux build prerequisites are a C/C++ compiler, CMake, Python, Git, and the
167
+ OpenSSL development package.
83
168
 
84
169
  ```bash
85
170
  npm install
86
171
  npm test
87
172
  ```
88
173
 
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`.
174
+ When no compatible prebuild is available, `npm install` builds the bundled
175
+ open62541 release and then compiles the N-API addon. To rebuild only the native
176
+ layer after changing C++ code, run `npm run build`.
90
177
 
91
178
  The open62541 build is static and position-independent so the resulting N-API addon does not need a runtime open62541 shared library.
92
179
 
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.
180
+ The pinned open62541 build uses its OpenSSL encryption backend. The initial
181
+ secure policy scope intentionally excludes deprecated Basic128Rsa15 and
182
+ Basic256 policies.
94
183
 
95
184
  ## Native integration test
96
185
 
@@ -102,9 +191,34 @@ python3 -m venv .build/test-venv
102
191
  PATH="$PWD/.build/test-venv/bin:$PATH" npm run test:native
103
192
  ```
104
193
 
194
+ The Python fixture is used for the general native suite but does not implement
195
+ OPC UA IndexRange semantics. Build the included open62541 test Server to verify
196
+ real partial array reads and writes:
197
+
198
+ ```bash
199
+ npm run build:test-server
200
+ OPCUA_TEST_SERVER_BINARY="$PWD/build/open62541-test-server" \
201
+ node test/native.integration.js
202
+ ```
203
+
204
+ Run the secure integration test against a real Server after placing its
205
+ certificate in the trust list:
206
+
207
+ ```bash
208
+ OPCUA_SECURE_ENDPOINT=opc.tcp://server:4840 \
209
+ OPCUA_CLIENT_CERTIFICATE=/pki/client.der \
210
+ OPCUA_CLIENT_PRIVATE_KEY=/pki/client-key.pem \
211
+ OPCUA_TRUSTED_CERTIFICATES=/pki/server.der \
212
+ npm run test:secure
213
+ ```
214
+
215
+ Set `OPCUA_USERNAME` and `OPCUA_PASSWORD` when the selected Endpoint uses a
216
+ Username identity token. `OPCUA_CLIENT_APPLICATION_URI` must match the URI in
217
+ the client certificate when it differs from the default.
218
+
105
219
  ## Stress test
106
220
 
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.
221
+ The stress test starts a real writable OPC UA server, browses the complete address space, performs repeated batched reads and writes, creates subscriptions containing all MonitoredItems, verifies change delivery for every point, and reports latency, throughput, memory, CPU, and event-loop delay.
108
222
 
109
223
  ```bash
110
224
  OPCUA_STRESS_POINTS=5000 \
@@ -127,7 +241,7 @@ To inject a real server outage after subscription creation, restart the fixture,
127
241
  OPCUA_NODERED_POINTS=1000 npm run test:nodered:reconnect
128
242
  ```
129
243
 
130
- See [BENCHMARK.md](BENCHMARK.md) for the recorded 1,000, 5,000, sustained 5,000, and 10,000 point baselines.
244
+ See [BENCHMARK.md](BENCHMARK.md) for the recorded 1,000, 5,000, 10,000, and sustained 20,000 point baselines.
131
245
 
132
246
  ## Scale defaults
133
247
 
@@ -135,7 +249,10 @@ See [BENCHMARK.md](BENCHMARK.md) for the recorded 1,000, 5,000, sustained 5,000,
135
249
  - Attribute read batch: 200 points.
136
250
  - Read/Write service batch: 500 points.
137
251
  - MonitoredItem creation batch: 500 points.
252
+ - Subscription capacity fallback: 5,000 MonitoredItems (automatically reduced by the server limit).
253
+ - Node-RED batch flush: 500 changes or 50 ms.
138
254
  - Subscription callback queue: 10,000 pending changes.
139
255
  - Parsed NodeId cache: 100,000 entries per connection.
256
+ - Native command queue: 256 pending commands per connection.
140
257
 
141
258
  Configured service batches are automatically clamped to the server's advertised OperationLimits.
@@ -0,0 +1,20 @@
1
+ # Third-party notices
2
+
3
+ ## open62541
4
+
5
+ This package contains open62541 v1.4.16 in source and executable form.
6
+ open62541 is licensed under the Mozilla Public License 2.0.
7
+
8
+ - Project: https://github.com/open62541/open62541
9
+ - Source version: https://github.com/open62541/open62541/tree/v1.4.16
10
+ - License copy: `vendor/open62541-LICENSE`
11
+ - Bundled source: `vendor/open62541-v1.4.16.tar.gz`
12
+
13
+ The open62541 source is not modified by this package. The native Node.js addon
14
+ links the open62541 static library with this package's separate C++ binding.
15
+
16
+ ## node-addon-api
17
+
18
+ This package bundles node-addon-api for offline source-build fallback.
19
+ node-addon-api is licensed under the MIT License; its package includes its own
20
+ license file.
package/binding.gyp CHANGED
@@ -17,7 +17,7 @@
17
17
  "cflags_cc!": ["-fno-exceptions"],
18
18
  "cflags_cc": ["-std=c++20"],
19
19
  "conditions": [
20
- ["OS=='linux'", {"libraries": ["-lpthread", "-lm"]}]
20
+ ["OS=='linux'", {"libraries": ["-lpthread", "-lm", "-lssl", "-lcrypto"]}]
21
21
  ]
22
22
  }
23
23
  ]
package/lib/client.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const { EventEmitter } = require('events');
4
4
  const { chunks } = require('./points');
5
5
  const { loadAddon } = require('./native');
6
+ const { normalizeConnectionOptions } = require('./security');
6
7
 
7
8
  function normalizeNotificationBatch(notification) {
8
9
  if (notification && Array.isArray(notification.changes)) {
@@ -31,6 +32,7 @@ class OpcUaClient extends EventEmitter {
31
32
  this.options = { ...options };
32
33
  this.handle = null;
33
34
  this.connecting = null;
35
+ this.replacing = null;
34
36
  this.closed = false;
35
37
  this.connectionState = 'idle';
36
38
  this.connectionToken = 0;
@@ -58,6 +60,11 @@ class OpcUaClient extends EventEmitter {
58
60
 
59
61
  async connect() {
60
62
  if (this.closed) throw new Error('OPC UA client is closed');
63
+ if (this.replacing) {
64
+ const error = new Error('OPC UA client is replacing a failed connection');
65
+ error.code = 'OPCUA_RECONNECTING';
66
+ throw error;
67
+ }
61
68
  if (this.handle != null) {
62
69
  if (this.connectionState === 'connected') return this.handle;
63
70
  const error = new Error(`OPC UA client is ${this.connectionState}`);
@@ -78,13 +85,14 @@ class OpcUaClient extends EventEmitter {
78
85
  }
79
86
 
80
87
  async #connect() {
88
+ const options = normalizeConnectionOptions(this.options);
81
89
  const addon = loadAddon();
82
90
  const token = ++this.connectionToken;
83
91
  this.connectionState = 'connecting';
84
92
  let handle;
85
93
  try {
86
94
  handle = await addon.connectAsync(
87
- this.options,
95
+ options,
88
96
  (state) => this.#onNativeState(token, state)
89
97
  );
90
98
  } catch (error) {
@@ -138,6 +146,10 @@ class OpcUaClient extends EventEmitter {
138
146
  if (connecting) {
139
147
  try { await connecting; } catch {}
140
148
  }
149
+ const replacing = this.replacing;
150
+ if (replacing) {
151
+ try { await replacing; } catch {}
152
+ }
141
153
  const handle = this.handle;
142
154
  this.handle = null;
143
155
  if (handle != null) {
@@ -258,8 +270,17 @@ class OpcUaClient extends EventEmitter {
258
270
  if (state.connected) {
259
271
  this.reconnectAttempt = 0;
260
272
  this.#refreshDiagnostics();
261
- } else if (state.terminal) {
262
- void this.#replaceTerminalClient(token, state);
273
+ } else if (state.terminal && !this.replacing) {
274
+ const replacing = this.#replaceTerminalClient(token, state);
275
+ this.replacing = replacing;
276
+ void replacing.then(
277
+ () => {
278
+ if (this.replacing === replacing) this.replacing = null;
279
+ },
280
+ () => {
281
+ if (this.replacing === replacing) this.replacing = null;
282
+ }
283
+ );
263
284
  }
264
285
  }
265
286
 
@@ -268,12 +289,13 @@ class OpcUaClient extends EventEmitter {
268
289
  this.connectionToken += 1;
269
290
  this.supervisorDiagnostics.terminalReplacements += 1;
270
291
  const handle = this.handle;
271
- this.handle = null;
272
- this.connectionState = 'failed';
292
+ this.connectionState = 'replacing';
273
293
  if (handle != null) {
274
294
  try { await loadAddon().disconnectAsync(handle); } catch {}
275
295
  }
296
+ if (this.handle === handle) this.handle = null;
276
297
  if (!this.closed) {
298
+ this.connectionState = 'failed';
277
299
  const error = new Error(`OPC UA connection failed: ${state.statusCode || 'Unknown'}`);
278
300
  error.code = 'OPCUA_CONNECTION_FAILED';
279
301
  this.emit('fault', error);
@@ -282,7 +304,7 @@ class OpcUaClient extends EventEmitter {
282
304
  }
283
305
 
284
306
  #scheduleReconnect() {
285
- if (this.closed || this.reconnectTimer || this.connecting) return;
307
+ if (this.closed || this.handle != null || this.reconnectTimer || this.connecting) return;
286
308
  const base = Math.max(100, Number(this.options.reconnectInterval) || 5000);
287
309
  const maximum = Math.max(base, Number(this.options.maxReconnectInterval) || 30000);
288
310
  const exponential = Math.min(maximum, base * (2 ** this.reconnectAttempt));
@@ -318,4 +340,23 @@ class OpcUaClient extends EventEmitter {
318
340
  }
319
341
  }
320
342
 
321
- module.exports = { OpcUaClient, effectiveBatchSize, normalizeNotificationBatch };
343
+ async function discoverEndpoints(options) {
344
+ const source = typeof options === 'string' ? { endpoint: options } : { ...options };
345
+ const endpoint = String(source.endpoint || '').trim();
346
+ if (!endpoint) {
347
+ const error = new Error('OPC UA endpoint is required');
348
+ error.code = 'OPCUA_INVALID_SECURITY_CONFIGURATION';
349
+ throw error;
350
+ }
351
+ return loadAddon().discoverEndpointsAsync({
352
+ endpoint,
353
+ requestTimeout: Number(source.requestTimeout) || 10000,
354
+ });
355
+ }
356
+
357
+ module.exports = {
358
+ OpcUaClient,
359
+ discoverEndpoints,
360
+ effectiveBatchSize,
361
+ normalizeNotificationBatch,
362
+ };
package/lib/native.js CHANGED
@@ -1,5 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ const path = require('node:path');
4
+ const { prebuildKey, resolvePrebuilt } = require('./prebuild');
5
+
3
6
  let injectedAddon;
4
7
  let loadedAddon;
5
8
 
@@ -7,17 +10,29 @@ function loadAddon() {
7
10
  if (injectedAddon) return injectedAddon;
8
11
  if (loadedAddon) return loadedAddon;
9
12
 
10
- try {
11
- loadedAddon = require('../build/Release/opcua_open62541.node');
12
- return loadedAddon;
13
- } catch (error) {
14
- const wrapped = new Error(
15
- `Native open62541 addon is unavailable: ${error.message}. ` +
16
- 'Run npm run build:open62541 && npm run build.'
17
- );
18
- wrapped.cause = error;
19
- throw wrapped;
13
+ const packageRoot = path.resolve(__dirname, '..');
14
+ const candidates = [
15
+ process.env.OPCUA_OPEN62541_ADDON,
16
+ resolvePrebuilt(packageRoot),
17
+ path.join(packageRoot, 'build', 'Release', 'opcua_open62541.node'),
18
+ ].filter(Boolean);
19
+ let lastError;
20
+ for (const candidate of candidates) {
21
+ try {
22
+ loadedAddon = require(candidate);
23
+ return loadedAddon;
24
+ } catch (error) {
25
+ lastError = error;
26
+ }
20
27
  }
28
+
29
+ const wrapped = new Error(
30
+ `Native open62541 addon is unavailable for ${prebuildKey()}: ` +
31
+ `${lastError?.message || 'no addon candidate found'}. ` +
32
+ 'Run npm run build:offline-prebuild to build this platform.'
33
+ );
34
+ wrapped.cause = lastError;
35
+ throw wrapped;
21
36
  }
22
37
 
23
38
  function setAddonForTests(addon) {