@tier0/opcua-client 0.1.0 → 0.4.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 +53 -2
- package/README.md +180 -14
- package/THIRD_PARTY_NOTICES.md +20 -0
- package/binding.gyp +1 -1
- package/lib/client.js +48 -7
- package/lib/connection-registry.js +68 -0
- package/lib/native.js +25 -10
- package/lib/points.js +187 -21
- package/lib/prebuild.js +42 -0
- package/lib/security-file-store.js +119 -0
- package/lib/security.js +96 -0
- package/node_modules/node-addon-api/LICENSE.md +9 -0
- package/node_modules/node-addon-api/README.md +95 -0
- package/node_modules/node-addon-api/common.gypi +21 -0
- package/node_modules/node-addon-api/except.gypi +25 -0
- package/node_modules/node-addon-api/index.js +14 -0
- package/node_modules/node-addon-api/napi-inl.deprecated.h +186 -0
- package/node_modules/node-addon-api/napi-inl.h +7244 -0
- package/node_modules/node-addon-api/napi.h +3420 -0
- package/node_modules/node-addon-api/node_addon_api.gyp +42 -0
- package/node_modules/node-addon-api/node_api.gyp +9 -0
- package/node_modules/node-addon-api/noexcept.gypi +26 -0
- package/node_modules/node-addon-api/nothing.c +0 -0
- package/node_modules/node-addon-api/package-support.json +21 -0
- package/node_modules/node-addon-api/package.json +481 -0
- package/node_modules/node-addon-api/tools/README.md +73 -0
- package/node_modules/node-addon-api/tools/check-napi.js +99 -0
- package/node_modules/node-addon-api/tools/clang-format.js +71 -0
- package/node_modules/node-addon-api/tools/conversion.js +301 -0
- package/nodes/browse.html +27 -2
- package/nodes/connection.html +293 -6
- package/nodes/connection.js +160 -23
- package/nodes/read.html +42 -3
- package/nodes/read.js +51 -12
- package/nodes/subscribe.html +57 -5
- package/nodes/subscribe.js +386 -99
- package/nodes/write.html +29 -2
- package/nodes/write.js +2 -1
- package/package.json +15 -4
- package/prebuilds/linux-x64-musl/opcua_open62541.node +0 -0
- package/scripts/build-offline-prebuild.sh +15 -0
- package/scripts/build-open62541.sh +14 -4
- package/scripts/build-test-server.sh +23 -0
- package/scripts/install.js +28 -0
- package/scripts/verify-offline-package.js +20 -0
- package/scripts/verify-runtime.js +20 -0
- package/src/addon.cc +707 -119
- package/vendor/open62541-LICENSE +373 -0
- 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
|
|
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
|
|
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,12 +6,16 @@ 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
|
|
10
|
-
- `tier0-opcua-write`: scalar
|
|
11
|
-
- `tier0-opcua-subscribe`: one
|
|
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
|
|
|
15
|
+
Every node includes bilingual Chinese and English guidance in the Node-RED Help
|
|
16
|
+
sidebar, covering its purpose, message contract, key settings, and operational
|
|
17
|
+
notes for industrial deployments.
|
|
18
|
+
|
|
15
19
|
## Point contract
|
|
16
20
|
|
|
17
21
|
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:
|
|
@@ -35,13 +39,41 @@ Browse output can be wired directly into Read or Subscribe. In the Read and Writ
|
|
|
35
39
|
|
|
36
40
|
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
41
|
|
|
38
|
-
|
|
42
|
+
New Read nodes default to compact results: `nodeId`, optional `alias`, `value`,
|
|
43
|
+
`statusCode`, and `sourceTimestamp`. Browse metadata and subscription-only point
|
|
44
|
+
settings are not repeated on every Read. **Value only** emits an array in point-
|
|
45
|
+
list order for batch output and a scalar for single/split output. **Full
|
|
46
|
+
metadata** preserves the original descriptor-rich output and includes client
|
|
47
|
+
diagnostics. Flow JSON created before this option existed continues to use Full
|
|
48
|
+
metadata until the node is edited and a result detail is selected.
|
|
49
|
+
|
|
50
|
+
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
51
|
|
|
40
|
-
- `callbackBatchSize`: maximum changes per
|
|
52
|
+
- `callbackBatchSize`: maximum changes per native callback and Node-RED output message.
|
|
53
|
+
- `callbackFlushIntervalMs`: maximum wait for a partially filled Node-RED output batch (default 50 ms).
|
|
41
54
|
- `callbackQueueSize`: hard native queue capacity.
|
|
42
55
|
- `queueMode`: `all` preserves pending changes; `latest` coalesces by MonitoredItem.
|
|
43
56
|
- `overflowPolicy`: `drop-oldest` or `drop-newest` when the hard capacity is reached.
|
|
44
|
-
- `outputMode`: `batch`
|
|
57
|
+
- `outputMode`: `batch` aggregates changes across Subscriptions; `single` preserves one message per change compatibility.
|
|
58
|
+
|
|
59
|
+
`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.
|
|
60
|
+
|
|
61
|
+
New Subscribe nodes default to compact notifications: `nodeId`, optional
|
|
62
|
+
`alias`, `value`, `statusCode`, and `sourceTimestamp`. Browse metadata, native
|
|
63
|
+
MonitoredItem handles, and sharding details are omitted. In batch **Value only**
|
|
64
|
+
mode, `msg.payload` is a value array and `msg.opcua.identifiers` is the matching
|
|
65
|
+
NodeId/alias array. Parallel arrays preserve order and repeated changes from the
|
|
66
|
+
same point. **Full metadata** preserves the original output for diagnostics and
|
|
67
|
+
Flow JSON created before this option existed continues to use it.
|
|
68
|
+
|
|
69
|
+
New Subscribe nodes also default to server queue size `1`, `discardOldest=true`,
|
|
70
|
+
and native queue mode `latest`. This keeps real-time state current during
|
|
71
|
+
backpressure instead of replaying stale values. Set queue mode to `all` and
|
|
72
|
+
increase the server queue size for pulses, alarms, counters, or history capture
|
|
73
|
+
where every intermediate transition must be retained. Existing Flow JSON keeps
|
|
74
|
+
its saved queue settings; runtime fallbacks remain compatible with older nodes.
|
|
75
|
+
|
|
76
|
+
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
77
|
|
|
46
78
|
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
79
|
|
|
@@ -51,15 +83,39 @@ Each connection reads the server's standard OperationLimits in one OPC UA Read r
|
|
|
51
83
|
|
|
52
84
|
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
85
|
|
|
86
|
+
## Native client ownership and command queue
|
|
87
|
+
|
|
88
|
+
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.
|
|
89
|
+
|
|
90
|
+
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.
|
|
91
|
+
|
|
92
|
+
`client.getDiagnostics().commandQueue` reports `depth`, `capacity`, `maxDepth`, `active`, `enqueued`, `completed`, `rejected`, and `timedOut`.
|
|
93
|
+
|
|
94
|
+
## Session ownership and duplicate-connection governance
|
|
95
|
+
|
|
96
|
+
One `tier0-opcua-connection` configuration node owns one native client and one
|
|
97
|
+
OPC UA Session. Read, Write, Browse, and Subscribe nodes that reference that
|
|
98
|
+
same configuration reuse the Session, including when the configuration is
|
|
99
|
+
shared across Node-RED flow tabs. Different Connection configuration nodes are
|
|
100
|
+
never merged implicitly because separate Sessions may be required for isolation.
|
|
101
|
+
|
|
102
|
+
When equivalent Connection configurations target the same endpoint, security
|
|
103
|
+
mode/policy, and user identity, the runtime emits a warning that an additional
|
|
104
|
+
Session will be created. Reuse the existing Connection configuration to avoid
|
|
105
|
+
duplicate Server load. Enable **Allow parallel Session** only when the extra
|
|
106
|
+
Session is intentional. Read/Write diagnostics and subscription status outputs
|
|
107
|
+
include `connectionGovernance`, with the equivalent and active configuration
|
|
108
|
+
counts.
|
|
109
|
+
|
|
54
110
|
## Connection and subscription recovery
|
|
55
111
|
|
|
56
112
|
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
113
|
|
|
58
|
-
Subscribe nodes retain a desired point map independently from
|
|
114
|
+
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
115
|
|
|
60
116
|
Connection diagnostics also expose the current channel/session state and supervisor counters: client generation, reconnect attempts/successes, terminal replacements, and last recovery duration.
|
|
61
117
|
|
|
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`.
|
|
118
|
+
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
119
|
|
|
64
120
|
```csv
|
|
65
121
|
nodeId,value,dataType
|
|
@@ -67,6 +123,29 @@ ns=2;s=Line1.Speed,1200,Double
|
|
|
67
123
|
ns=2;s=Line1.Enabled,true,Boolean
|
|
68
124
|
```
|
|
69
125
|
|
|
126
|
+
## Arrays and IndexRange
|
|
127
|
+
|
|
128
|
+
Read and Write descriptors accept the standard OPC UA `indexRange` field. The
|
|
129
|
+
legacy `range` spelling is normalized to `indexRange`. One-dimensional examples
|
|
130
|
+
are `0` and `2:5`; matrix ranges use a comma, for example `0:1,2:3`. Invalid or
|
|
131
|
+
descending ranges are rejected before an OPC UA service call.
|
|
132
|
+
|
|
133
|
+
Write values may be scalar JavaScript values, arrays, or rectangular nested
|
|
134
|
+
arrays. Nested arrays are converted to OPC UA matrix dimensions; ragged or empty
|
|
135
|
+
arrays are rejected. A direct array payload is treated as the value when the
|
|
136
|
+
message or Write node identifies one target NodeId. A configured single
|
|
137
|
+
`useInput` point behaves the same way. Set `msg.opcua.pointList = true` only when
|
|
138
|
+
an array payload must be forced to mean a dynamic list of point descriptors.
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"topic": "ns=2;s=Recipe.Steps",
|
|
143
|
+
"indexRange": "4:6",
|
|
144
|
+
"dataType": "Double",
|
|
145
|
+
"payload": [12.5, 13.0, 13.5]
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
70
149
|
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
150
|
|
|
72
151
|
## Browse tree selection
|
|
@@ -77,20 +156,79 @@ The selected Variable descriptors are saved with the Browse node. Each input mes
|
|
|
77
156
|
|
|
78
157
|
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
158
|
|
|
80
|
-
##
|
|
159
|
+
## Secure connections
|
|
160
|
+
|
|
161
|
+
Connection nodes support `None/None` and `Sign` or `SignAndEncrypt` with
|
|
162
|
+
`Basic256Sha256`. Secure connections require a client certificate, its private
|
|
163
|
+
key, and at least one trusted Server or CA certificate. Trust is strict: an
|
|
164
|
+
empty trust list is rejected instead of silently accepting every Server
|
|
165
|
+
certificate. Optional CRL files can be supplied as a newline-separated list.
|
|
166
|
+
Password-protected PEM private keys use the separate Node-RED credential field,
|
|
167
|
+
so the password is not stored in the normal Flow configuration.
|
|
168
|
+
|
|
169
|
+
The client Application URI can be configured when the URI embedded in the
|
|
170
|
+
client certificate differs from the open62541 default. Username authentication
|
|
171
|
+
uses the selected secure endpoint and its advertised UserTokenPolicy.
|
|
172
|
+
|
|
173
|
+
The Connection editor can call `GetEndpoints`, filters the response to the
|
|
174
|
+
implemented policies, and selects an endpoint compatible with Anonymous or
|
|
175
|
+
Username authentication. Client certificates, private keys, trusted Server/CA
|
|
176
|
+
certificates, and CRLs can be uploaded from the editor. Uploaded files are kept
|
|
177
|
+
under `<Node-RED userDir>/opcua-pki/<connection>/` with owner-only file
|
|
178
|
+
permissions and the managed runtime path is stored in the Connection
|
|
179
|
+
configuration. Existing and advanced deployments can still enter filesystem
|
|
180
|
+
paths visible to the Node-RED runtime directly. Uploaded private-key bytes are
|
|
181
|
+
never stored in the Flow JSON, and removing the Connection node removes its
|
|
182
|
+
managed security-file directory.
|
|
183
|
+
|
|
184
|
+
## Offline installation
|
|
185
|
+
|
|
186
|
+
The published package contains a prebuilt `linux-x64-musl` native addon for the
|
|
187
|
+
Node-RED Alpine image, the pinned open62541 v1.4.16 source archive, and its
|
|
188
|
+
`node-addon-api` build dependency. The same Node-API binary is verified on Node
|
|
189
|
+
20.19.6 and Node 22.22.2. Installing the tarball on the supported target does
|
|
190
|
+
not contact npm or GitHub and does not invoke a compiler:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
npm install ./tier0-opcua-client-0.4.0.tgz --offline
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The loader uses the bundled native addon directly. Set
|
|
197
|
+
`OPCUA_CLIENT_FORCE_BUILD=1` only to force a source rebuild. Unsupported
|
|
198
|
+
platforms fall back to the bundled open62541 source and therefore still require
|
|
199
|
+
a local C/C++ toolchain, CMake, Python, Node headers, and OpenSSL development
|
|
200
|
+
files. No network is needed for open62541 itself.
|
|
201
|
+
|
|
202
|
+
Create the supported prebuild and package on Linux with:
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
npm ci --ignore-scripts
|
|
206
|
+
npm run build:offline-prebuild -- --nodedir=/usr/local
|
|
207
|
+
npm pack
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
See `THIRD_PARTY_NOTICES.md` for the bundled open62541 and node-addon-api
|
|
211
|
+
licenses.
|
|
81
212
|
|
|
82
|
-
|
|
213
|
+
## Source build
|
|
214
|
+
|
|
215
|
+
Linux build prerequisites are a C/C++ compiler, CMake, Python, Git, and the
|
|
216
|
+
OpenSSL development package.
|
|
83
217
|
|
|
84
218
|
```bash
|
|
85
219
|
npm install
|
|
86
220
|
npm test
|
|
87
221
|
```
|
|
88
222
|
|
|
89
|
-
|
|
223
|
+
When no compatible prebuild is available, `npm install` builds the bundled
|
|
224
|
+
open62541 release and then compiles the N-API addon. To rebuild only the native
|
|
225
|
+
layer after changing C++ code, run `npm run build`.
|
|
90
226
|
|
|
91
227
|
The open62541 build is static and position-independent so the resulting N-API addon does not need a runtime open62541 shared library.
|
|
92
228
|
|
|
93
|
-
|
|
229
|
+
The pinned open62541 build uses its OpenSSL encryption backend. The initial
|
|
230
|
+
secure policy scope intentionally excludes deprecated Basic128Rsa15 and
|
|
231
|
+
Basic256 policies.
|
|
94
232
|
|
|
95
233
|
## Native integration test
|
|
96
234
|
|
|
@@ -102,9 +240,34 @@ python3 -m venv .build/test-venv
|
|
|
102
240
|
PATH="$PWD/.build/test-venv/bin:$PATH" npm run test:native
|
|
103
241
|
```
|
|
104
242
|
|
|
243
|
+
The Python fixture is used for the general native suite but does not implement
|
|
244
|
+
OPC UA IndexRange semantics. Build the included open62541 test Server to verify
|
|
245
|
+
real partial array reads and writes:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
npm run build:test-server
|
|
249
|
+
OPCUA_TEST_SERVER_BINARY="$PWD/build/open62541-test-server" \
|
|
250
|
+
node test/native.integration.js
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Run the secure integration test against a real Server after placing its
|
|
254
|
+
certificate in the trust list:
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
OPCUA_SECURE_ENDPOINT=opc.tcp://server:4840 \
|
|
258
|
+
OPCUA_CLIENT_CERTIFICATE=/pki/client.der \
|
|
259
|
+
OPCUA_CLIENT_PRIVATE_KEY=/pki/client-key.pem \
|
|
260
|
+
OPCUA_TRUSTED_CERTIFICATES=/pki/server.der \
|
|
261
|
+
npm run test:secure
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Set `OPCUA_USERNAME` and `OPCUA_PASSWORD` when the selected Endpoint uses a
|
|
265
|
+
Username identity token. `OPCUA_CLIENT_APPLICATION_URI` must match the URI in
|
|
266
|
+
the client certificate when it differs from the default.
|
|
267
|
+
|
|
105
268
|
## Stress test
|
|
106
269
|
|
|
107
|
-
The stress test starts a real writable OPC UA server, browses the complete address space, performs repeated batched reads and writes, creates
|
|
270
|
+
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
271
|
|
|
109
272
|
```bash
|
|
110
273
|
OPCUA_STRESS_POINTS=5000 \
|
|
@@ -127,7 +290,7 @@ To inject a real server outage after subscription creation, restart the fixture,
|
|
|
127
290
|
OPCUA_NODERED_POINTS=1000 npm run test:nodered:reconnect
|
|
128
291
|
```
|
|
129
292
|
|
|
130
|
-
See [BENCHMARK.md](BENCHMARK.md) for the recorded 1,000, 5,000,
|
|
293
|
+
See [BENCHMARK.md](BENCHMARK.md) for the recorded 1,000, 5,000, 10,000, and sustained 20,000 point baselines.
|
|
131
294
|
|
|
132
295
|
## Scale defaults
|
|
133
296
|
|
|
@@ -135,7 +298,10 @@ See [BENCHMARK.md](BENCHMARK.md) for the recorded 1,000, 5,000, sustained 5,000,
|
|
|
135
298
|
- Attribute read batch: 200 points.
|
|
136
299
|
- Read/Write service batch: 500 points.
|
|
137
300
|
- MonitoredItem creation batch: 500 points.
|
|
301
|
+
- Subscription capacity fallback: 5,000 MonitoredItems (automatically reduced by the server limit).
|
|
302
|
+
- Node-RED batch flush: 500 changes or 50 ms.
|
|
138
303
|
- Subscription callback queue: 10,000 pending changes.
|
|
139
304
|
- Parsed NodeId cache: 100,000 entries per connection.
|
|
305
|
+
- Native command queue: 256 pending commands per connection.
|
|
140
306
|
|
|
141
307
|
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
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
|
|
5
|
+
function canonicalize(value) {
|
|
6
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
7
|
+
if (value && typeof value === 'object') {
|
|
8
|
+
return Object.fromEntries(
|
|
9
|
+
Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function connectionSignature(options) {
|
|
16
|
+
const source = options || {};
|
|
17
|
+
const identity = {
|
|
18
|
+
endpoint: String(source.endpoint || '').trim(),
|
|
19
|
+
securityMode: String(source.securityMode || 'None'),
|
|
20
|
+
securityPolicy: String(source.securityPolicy || 'None'),
|
|
21
|
+
user: String(source.user || '').trim(),
|
|
22
|
+
};
|
|
23
|
+
const serialized = JSON.stringify(canonicalize(identity));
|
|
24
|
+
return crypto.createHash('sha256').update(serialized).digest('hex');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class ConnectionRegistry {
|
|
28
|
+
constructor() {
|
|
29
|
+
this.entries = new Map();
|
|
30
|
+
this.configurationCount = 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
register(options) {
|
|
34
|
+
const signature = connectionSignature(options);
|
|
35
|
+
let records = this.entries.get(signature);
|
|
36
|
+
if (!records) {
|
|
37
|
+
records = new Set();
|
|
38
|
+
this.entries.set(signature, records);
|
|
39
|
+
}
|
|
40
|
+
const record = {};
|
|
41
|
+
records.add(record);
|
|
42
|
+
this.configurationCount += 1;
|
|
43
|
+
let unregistered = false;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
duplicate: records.size > 1,
|
|
47
|
+
diagnostics: () => ({
|
|
48
|
+
equivalentConfigurations: records.size,
|
|
49
|
+
duplicateConfiguration: records.size > 1,
|
|
50
|
+
activeConfigurations: this.configurationCount,
|
|
51
|
+
}),
|
|
52
|
+
unregister: () => {
|
|
53
|
+
if (unregistered) return false;
|
|
54
|
+
unregistered = true;
|
|
55
|
+
records.delete(record);
|
|
56
|
+
this.configurationCount = Math.max(0, this.configurationCount - 1);
|
|
57
|
+
if (records.size === 0) this.entries.delete(signature);
|
|
58
|
+
return true;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get size() {
|
|
64
|
+
return this.configurationCount;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { ConnectionRegistry, connectionSignature };
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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) {
|