Skip to content

fix(binding-opcua): own the octet-stream codec for opc.tcp forms (#1400) - #1574

Merged
danielpeintner merged 33 commits into
eclipse-thingweb:masterfrom
node-opcua:wip/octet-stream-e2e
Nov 5, 2026
Merged

danielpeintner merged 33 commits into
eclipse-thingweb:masterfrom
node-opcua:wip/octet-stream-e2e

Conversation

@erossignon

@erossignon erossignon commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1400.

Symptom

Adding "contentType": "application/octet-stream" to an OPC UA form breaks the read:

TypeError: dataType.toLowerCase is not a function
    at OctetstreamCodec.valueToBytes

Cause

binding-opcua never owned its media types, so core's generic OctetstreamCodec was handed OPC UA content and tried to treat a node-opcua DataType as a string. Scheme-scoped codec registration only became available with #1572 (merged today).

Fix

The binding registers its own codecs for opc.tcp forms. application/octet-stream now means the raw bytes of a ByteString; any other target type is refused with an error naming the form and the actual OPC UA type, rather than mangling it. The default stays application/json (W3C WoT TD 1.1 §5.3.4.2) — OPC 10101 §6.5.3 is conditional and does not set a default.

Tests

npm run test --workspace @node-wot/binding-opcua — 316 passing, 1 pending (pre-existing xit).

file covers
test/octet-stream-e2e-test.ts scenarios A–G from the issue, including the exact failing TD
test/octet-stream-shapes-test.ts contentType x OPC UA type matrix
test/contenttype-write-test.ts write round-trips: JSON, opcua+json, octet-stream, 1.04/1.05

The octet-stream refusal is asserted strictly: a base64 payload written to a Double must name both the ByteString requirement and the actual type. An earlier version of that test accepted either that message or the codec's must be a Buffer, and only ever hit the second, so the refusal went unexercised.

To see it fail without the fix: put the issue's TD (a Double with contentType: application/octet-stream) against master and the read throws the TypeError above. On this branch the same form is refused with a message naming the form and the type.

Please do not squash

The 12 commits are ordered to be read in sequence — negotiation POC, then the write path, then codec ownership, then the 1.05 encoding — and one of them is a breaking change (refactor(binding-opcua)!: stop exposing node-opcua internal shapes). Squashing buries that marker in a body nobody reads and loses the bisect points. A merge commit or a rebase merge both keep them.

Caveats

@codecov

codecov Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.28188% with 69 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.44%. Comparing base (62c717d) to head (eaad6a6).
⚠️ Report is 8 commits behind head on master.

Files with missing lines Patch % Lines
...ges/binding-opcua/src/opcua-content-negotiation.ts 92.93% 31 Missing and 3 partials ⚠️
...ackages/binding-opcua/src/opcua-protocol-client.ts 83.33% 25 Missing and 1 partial ⚠️
packages/binding-opcua/src/ua-binary.ts 94.83% 6 Missing and 2 partials ⚠️
.../binding-opcua/src/codecs/opcua-ua-binary-codec.ts 98.98% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1574      +/-   ##
==========================================
+ Coverage   78.76%   79.44%   +0.68%     
==========================================
  Files          91       91              
  Lines       16149    16533     +384     
  Branches     1576     1674      +98     
==========================================
+ Hits        12720    13135     +415     
+ Misses       3404     3367      -37     
- Partials       25       31       +6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.


| Content-Type | body encoding |
| -------------------------------------------------------- | ------------------------------ |
| `application/octet-stream`, `application/opcua+uabinary` | OPC UA Binary (§5.2) |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, a hint should be provided that this binding only supports OPC UA Binary, right?

@erossignon erossignon Sep 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact that whole point of this PR and the shift I want to operate, we cannot support OPC UA Binary in an universal way.
This could work for datavalue containing basic types as scalar arry or matrix, but will fall short for datavalue containing extension object. In OPC UA Binary, extension object can only be decoded if the consumer knows to get the data type definitons of the extensions object, this information is not stored in the datavalue bin blob, but requires additional complex round trip ( read, browse, etc) with the OPC UA server

This is the reason I want to suggest that hte default OPCUA encoding in WOT to be the OPCUA+JSON representation, that is self-contained, and provide different flavors from short representation (number=> number, extension object=> json object) to reversible representation (Full dataValue with types nodes and extra opcua upper structure).

=> shifting default => binary to OPCUA/JSON (that is normative) is something I want to propose to the WOT OPCUA group is the next release of the spec.

https://reference.opcfoundation.org/specs/OPC-10000-6/annex-g

and this PR will serve as the proof of concept and demonstrator for why it has to be this way.

@danielpeintner

Copy link
Copy Markdown
Member

I did run the same example/process as in #1400 once again with this PR.

I am still getting the same error dataType.toLowerCase is not a function

CMD shell
opcua-test-etienne>node client.js
Script error: TypeError: dataType.toLowerCase is not a function
    at OctetstreamCodec.valueToBytes (opcua-test-etienne\node_modules\@node-wot\core\dist\codecs\octetstream-codec.js:189:78)
    at ContentSerdes.valueToContent (opcua-test-etienne\node_modules\@node-wot\core\dist\content-serdes.js:111:27)
    at OPCUAProtocolClient._dataValueToContent (opcua-test-etienne\node_modules\@node-wot\binding-opcua\dist\opcua-protocol-client.js:420:39)
    at opcua-test-etienne\node_modules\@node-wot\binding-opcua\dist\opcua-protocol-client.js:242:25
    at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
    at async OPCUAProtocolClient.readResource (opcua-test-etienne\node_modules\@node-wot\binding-opcua\dist\opcua-protocol-client.js:236:25)
    at async ConsumedThing.readProperty (opcua-test-etienne\node_modules\@node-wot\core\dist\consumed-thing.js:377:25)
    at async opcua-test-etienne\client.js:36:27

To reproduce the test

JS Code
// client.js
const { Servient } = require("@node-wot/core");
const { OPCUAClientFactory } = require( "@node-wot/binding-opcua");

// create Servient and add binding
const servient = new Servient();
servient.addClientFactory(new OPCUAClientFactory());

const thingDescription = {
    "@context": "https://www.w3.org/2019/wot/td/v1",
    "@type": ["Thing"],
    securityDefinitions: { nosec_sc: { scheme: "nosec" } },
    security: "nosec_sc",
    properties: {
        pumpSpeed: {
            description: "the pump speed",
            type: "number",
            forms: [
                {
                    href: "opc.tcp://opcuademo.sterfive.com:26543?id=ns=1;s=PumpSpeed",
                    contentType: "application/octet-stream",
                    op: ["readproperty", "observeproperty"],
                },
            ],
        },
    },
};

servient
    .start()
    .then(async (WoT) => {
        try {
            const thing = await WoT.consume(thingDescription);

            // read property
            const read1 = await thing.readProperty("pumpSpeed");
            console.log("pumpSpeed value is: ", await read1.value());
        } catch (err) {
            console.error("Script error:", err);
        }
    })
    .catch((err) => {
        console.error("Start error:", err);
    });

@erossignon

erossignon commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor Author

I did run the same example/process as in #1400 once again with this PR.

I am still getting the same error dataType.toLowerCase is not a function

That stack is master's code, not this branch.

Yours goes _dataValueToContent → ContentSerdes.valueToContent → OctetstreamCodec.
On this branch _dataValueToContent never calls ContentSerdes — it goes through
encodeDataValue. Same function name, different body.

The install is why: npm install <repo>.git#pull/1574/head pulls the repo root,
which is private: true and only a workspaces container. It publishes no
@node-wot/*, so your core and binding-opcua still came from npm.

Quick check — 1 means master, 0 means this PR:

grep -c "contentSerDes.valueToContent(dataValue" node_modules/@node-wot/binding-opcua/dist/opcua-protocol-client.js

To get the branch:

git clone https://github.com/eclipse-thingweb/node-wot.git
cd node-wot
git fetch origin pull/1574/head:pr-1574
git checkout pr-1574
npm ci
npm run build
npm pack --workspace @node-wot/core --workspace @node-wot/binding-opcua

That drops the two tarballs in the repo root. Then from your test project:

npm install ../node-wot/node-wot-core-1.0.0.tgz ../node-wot/node-wot-binding-opcua-1.0.0.tgz

I ran your client.js that way. PumpSpeed is a Double, so octet-stream is refused —
deliberately, that is the fix:

binding-opcua: contentType 'application/octet-stream' is only supported when the value
is a ByteString, but form '...id=ns=1;s=PumpSpeed' is Double. Use application/json for
the bare value, or application/opcua+json;type=DataValue ...

@danielpeintner

Copy link
Copy Markdown
Member

Sorry, you are right; it really picked the wrong code.

I checked out some examples, and it seems to work (I want to do some more tests, so please be patient with me), but I also have some questions.

  • "contentType": "application/json" is the default and reports for a Double value as a double result
  • "contentType": "application/opcua+json" is different in a sense that it provides the full JSON container. Like the following, where the value is in the Body
    {
      "Value": { "Type": 11, "Body": 299.2622871314578 },
      "SourceTimestamp": 2026-10-05T09:37:57.439Z
    }
    
  • What is the difference between "contentType": "application/opcua+octet-stream" and "contentType": "application/octet-stream" . It is the same again... "opcua+" version always reports the JSON object structure with the value inside? What surprised me a bit is the following. Setting application/octet-stream fails with error supported when the value is a ByteString while application/opcua+octet-stream works just fine. Any rationale for that? Shouldn't it fail again?

@danielpeintner

Copy link
Copy Markdown
Member

Note: The TD is about describing what is on the wire. Only the octet-stream is what should work. The JSON format is an internal format of the node-opcua library. It does not represent what is on the wire. Does that make sense to you?

For example, in the case of OPCUA SOAP, the contentType should match XML.

But here we are interested in OPCUA Binary encoding.

@erossignon

Copy link
Copy Markdown
Contributor Author

The octet-stream / opcua+octet-stream split is the one proposed on #1400 (comment):

application/opcua+octet-stream: the value in OPC UA Binary (OPC 10000-6 §5.2). It carries its own type id, so a Double works. value() decodes it back, which is why you get the same object.
application/octet-stream: the raw bytes of a single ByteString (images, files). Anything else is refused.

What's on the wire.

On opc.tcp, the wire is always OPC UA Binary, and it carries a whole ReadResponse (header, status codes, DataValue), not the bare value. No contentType can describe that. The form's contentType describes the representation the binding hands to the consumer.

The JSON is not node-opcua's. application/opcua+json is the OPC UA JSON encoding (OPC 10000-6 §5.4). In your sample, Type: 11 is the built-in type id of Double (1.04 names; 1.05 calls it UaType / Value).

One point is still open: OPC 10000-6 Table 80 maps application/octet-stream to OPC UA Binary, and this PR gives it to ByteString instead. The reasoning is in decisions.md, D4..

@danielpeintner

Copy link
Copy Markdown
Member

The contentType is there to describe the wire format. However, since we are in the JavaScript world, we report JSON.

Having said that. I think it should be forbidden to put "application/json" in the context of OPC UA, since it is never the wire format.
Sharing an OPC UA ThingDescription claiming contentType is JSON is simply wrong.

@sebastiankb @egekorkan please chime in

@sebastiankb

Copy link
Copy Markdown
Contributor

I agree with @danielpeintner

The OPC UA Binding 1.0 essentially targets OPC UA Client/Server over opc.tcp, where the encoding on the wire is always UA Binary. In a TD, the form's contentType tells the consumer what is actually exchanged over the protocol. It does not describe a representation that a library produces internally after decoding.

application/opcua+json never appears on the wire in Client/Server, so it should not be used for opc.tcp forms for now. It may become relevant when we consider describing HTTP-based OPC UA servers or PubSub over MQTT.

From my point of view, the only open question is application/octet-stream vs. application/opcua+uabinary vs. application/opcua+octet-stream:

  • application/octet-stream: OPC 10101 §6.5.3 clearly expects this generic binary content type. I remember the discussion in the working group: we agreed on this not perfect media type because the opc.tcp scheme already tells us that the payload is UA Binary. This is also aligned with Table 80 of OPC 10000-6. Therefore, I would not reuse it for raw ByteString content (D4), as this would contradict both specifications.
  • application/opcua+uabinary: This is actually the ideal solution, and I'm wondering why we did not consider it at that time. Could it be that it was newly introduced in OPC 10000-6? We should definitely adopt it in the next version of the WoT OPC UA Binding.
  • application/opcua+octet-stream: I think this should not be used anymore in the future, since it is not defined in any OPC UA or IANA.

To keep things simple, I would suggest that the implementation keeps the existing contentType variations for now for backward compatibility. However, I would ask @erossignon to change the behavior of application/octet-stream in this PR so that the payload is treated as UA Binary and value() returns the decoded value, i.e., the consumer gets the same result as with application/json. This will ensure that Thing Descriptions following OPC 10101, which uses application/octet-stream, work as expected.

@erossignon

Copy link
Copy Markdown
Contributor Author

Agreed: on opc.tcp forms, application/octet-stream now carries OPC UA Binary, and value() returns what application/json returns. application/json, application/opcua+json and application/opcua+octet-stream stay as they are, for backward compatibility only. Raw ByteString bytes (D4) are withdrawn.

The full plan: octet-stream-ua-binary-plan.md.

What the request leaves open. On opc.tcp the wire carries whole service messages (a ReadResponse, a PublishResponse, …), so the payload of a form has to be one part of them, and neither OPC 10101 nor Part 6 says which. Proposal: the DataValue for properties, the arguments for actions. Each choice below has a proposal, so the work can go on and change later:

Id Operation Question Proposal
R1 readproperty Is the payload the DataValue? yes, with the fields the server sent
O1 observeproperty The same payload as R1? yes, one DataValue per notification
W1 writeproperty A DataValue with optional fields? yes; a bare value gives a value-only DataValue
W2 writeproperty Write a structure with octet-stream? yes, by value as JSON (A3); refused until then
A1 invokeaction How are the arguments encoded? KeyValuePair[], names kept (not what is on the wire, which is Variant[] by position)
A2 invokeaction StatusCode of the call in the output? no
A3 write and input How does a consumer pass a structure? OPC UA JSON 1.05 with nsu=; the binding encodes it with its session

On application/opcua+uabinary: it is not new. Part 6 1.05 Table 80 gives it the same meaning as application/octet-stream and keeps it "only for backward compatibility", as it is not registered with IANA. So application/octet-stream is also the type Part 6 prefers. Not added here.

I am starting the implementation on this PR now, as a short series of commits that can be reverted as one range if you prefer another choice. node-opcua stays on 2.184; a later commit moves to the release that contains node-opcua/node-opcua#1835, which aligns node-opcua's OPC UA JSON on Part 6 (namespace URIs, matrices, structures, unions).

Caveats:

  • arrayBuffer() of a structure from a companion specification can only be read with that server's DataTypeDefinition, which WoT does not give the consumer (plan §7).
  • Structures in writes and action inputs (W2, A3) are refused with a clear message in this PR.
  • ByteString content is now wrapped in a DataValue, so arrayBuffer() is no longer the raw file; a media type for files can come later.

@erossignon

Copy link
Copy Markdown
Contributor Author

Question A3 (writing a structure, or passing one as an action argument) now has its own issue, #1583, so it can be settled apart from this review. Until then this PR refuses a structure write with a message that links to the issue. The binding is now on node-opcua 2.187.10: OPC UA JSON 1.05 names namespaces by URI (nsu=), and the binding passes the session's namespace table to every JSON encoder and decoder.

@danielpeintner

Copy link
Copy Markdown
Member

I did some basic testing...

TD with contentType application/octet-stream and application/json report

  • the actual typed value (e.g., number)
  • I do understand that you want to keep application/json for backwards compatibility. Shall we do that? Technically, it is wrong. @sebastiankb @egekorkan @relu91 ?

TD with contentType application/opcua+octet-stream and application/opcua+json report

  • the wrapped value (e.g., value: {Value: { Type: 11, Body: 140.70231599209075 }, SourceTimestamp: 2026-10-21T09:47:09.797Z })
  • In this case the type of the TD interaction also needs to say object, even though the Body value is a number (otherwise the InteractionOutput type check fails). In general I am not sure if a user should even be allowed to see the wrapped value at all. In HTTP we don't report the headers and what not either...
  • Same question as above with still allowing JSON?

As said elsewhere, I think MY only valid option would be application/opcua+octet-stream and it should not report the wrapped value but the actual typed value. Having said that, I don't know whether we can fix that in the OPC UA spec?

I am open to opinions!

@relu91

relu91 commented Oct 21, 2026

Copy link
Copy Markdown
Member

My points:

  • It's preferable to have backwards compatibility, but application/json was wrong, and we are targeting the release of 1.0.0, which will include other breaking changes; therefore, I'm okay with dropping it.
  • application/opcua+octet-stream is my preferred solution too, as I'm still not convinced about feat(core): register codecs per URI scheme (completes #1412) #1572, which included a mechanism only used as a workaround for this OPC UA binding in the core.

@sebastiankb

Copy link
Copy Markdown
Contributor

Sorry for the contradictory directions in this discussion.

The root cause is that the WoT OPC UA Binding 1.0 specifies the generic application/octet-stream. This causes problems in WoT implementations, which assume that the contentType alone defines how the payload is decoded.

After discussing this with @danielpeintner, we agree not to change this philosophy in node-wot. So instead of application/octet-stream, we should use application/opcua+uabinary. I know this contradicts Binding 1.0, but it is already planned for Binding 1.1.

To keep things clean, I suggest that this PR:

  • focuses only on application/opcua+uabinary and removes all other contentType variations
  • decodes only the value itself

Returning the complete DataValue container (e.g. with SourceTimestamp) should be out of scope for now and can be addressed in the next node-wot update.

Again, apologies for the confusion.

@sebastiankb

Copy link
Copy Markdown
Contributor

@relu91

* `application/opcua+octet-stream` is my preferred solution too, as I'm still not convinced about [feat(core): register codecs per URI scheme (completes #1412) #1572](https://github.com/eclipse-thingweb/node-wot/pull/1572), which included a mechanism only used as a workaround for this OPC UA binding in the core.

I hope, it is also ok to use application/opcua+uabinary instead, since you find this also in the UA spec here: https://reference.opcfoundation.org/specs/OPC-10000-6/7.4.2

@erossignon

Copy link
Copy Markdown
Contributor Author

Thanks for the clarification. Before the details, what it means for this PR: it is mostly an unwinding. Of the four contentTypes the PR implements, three are removed and the fourth is renamed (table below). Postponing the DataValue would cut further: today a TD can get the status and timestamps of a value (application/opcua+json;type=DataValue, application/opcua+octet-stream); with the value alone, no TD could.

Here is how I would implement your request, with one point where I disagree.

application/opcua+uabinary becomes the only contentType for opc.tcp, and by default value() returns the decoded value only. That is the mode you ask for.

Where I disagree is postponing the DataValue. The payload already is the OPC UA DataValue, and a parameter of the same media type can select what value() returns:

contentType value()
application/opcua+uabinary 299.26
…;type=Variant {"UaType":11,"Value":299.26}
…;type=DataValue adds Status, SourceTimestamp, ServerTimestamp

With the value alone:

  • an observed value loses its SourceTimestamp, the time it changed;
  • a Bad or Uncertain read comes back as a plain value, because the read path does not check the status;
  • different values give the same JSON: a ScanData union holding String "3005FB63" and one holding Custom "3005FB63" both give "ScanData":"3005FB63".

The content type alone still decides the decoding: the parameter is part of it, and core already passes parameters to the codec. No core change, and no per-scheme codec (#1572).

What this decision changes in the PR

Change
Kept the OPC UA Binary codec, the write path, structure decoding, namespace tables: renamed from octet-stream to opcua+uabinary
Removed (code) OpcuaJSONCodec, OpcuaBinaryCodec and the JSON data schemas: about 550 of the binding's 2,600 source lines
Removed (API) those three exports of @node-wot/binding-opcua
Removed (TDs) application/json, application/opcua+json, application/opcua+octet-stream, application/octet-stream; each gives an error naming its replacement
Tests about 190 of 364 deleted or moved to the new contentType (mostly the 32-shape matrix), about 40 renamed
Docs the decision notes and content-type tables rewritten
;type=Variant / ;type=DataValue about 30 more lines and two columns of the test matrix: the only extra cost compared to value-only

One question: a form without contentType is application/json per TD 1.1, so it would now fail. That includes 15 forms in node-wot's OPC UA examples, which I would update. OK, or should the binding read it as application/opcua+uabinary?

…eb#1400

Reads one Double over a real OPC UA connection once per combination of
contentType and schema-type placement, and prints the result table.

Assertions pin the current master behaviour so the test goes red when
eclipse-thingweb#1400 is fixed: application/octet-stream fails in every form with the
same OctetstreamCodec error Daniel reported when he opened the issue.

Run against PR eclipse-thingweb#1449 the matrix differs - only the row declaring type
on BOTH the affordance and the form passes, which is why his report
against that PR was correct.
Twelve OPC UA shapes - scalars, arrays, LocalizedText, Variant,
ExtensionObject - crossed with six contentType spellings. Self-contained:
registers its own namespace and variables on the fixture server, so the
same file drops into any checkout.

Also records the JS type value() returns and the raw wire bytes, which is
how the float32 narrowing under application/octet-stream was found.
OPC UA payloads no longer go through the global ContentSerdes registry,
where application/octet-stream belongs to Modbus and silently narrows a
64-bit Double to a big-endian float32.

opcua-content-negotiation.ts is now the single place a form's contentType
becomes a representation decision: bare value, Variant, DataValue, OPC UA
Binary, or raw bytes for a genuine ByteString. Anything else is refused
with a message naming the form, the actual OPC UA dataType, and a usable
alternative, instead of failing inside a codec that knows nothing about
OPC UA.

The default stays application/json, which W3C WoT TD 1.1 5.3.4.2 fixes
normatively and a binding may not redefine.

153 passing, no regressions.
ByteString is the one shape for which application/octet-stream is
legitimate, and it was untested - the only legal use of that media type
had no coverage.

Int64 now carries 2^53+1 as an explicit [high, low] pair with
arrayType Scalar. A plain JS number literal silently rounds to 2^53
before reaching the server, so the previous input proved nothing; and
node-opcua throws without an explicit arrayType, which is what hung the
earlier run inside before().
Write now resolves contentType the same way read does, so the two agree
on what a flavour means, and adds a write round-trip test per flavour.

The test found two real bugs:

- OpcuaJSONCodec.valueToBytes encoded with "ascii" in three places, so
  any non-ASCII string was corrupted on write - "hello woerld" came back
  with a replacement character. Pre-existing on master, unrelated to this
  work, now utf-8.
- Int64 needs an explicit arrayType: coerceInt64 yields a [high, low]
  pair that node-opcua cannot distinguish from a two-element array.

It also pinned down the limit of what a binding can fix alone.
ConsumedThing.writeProperty calls ContentSerdes.valueToContent before the
binding is reached, so on write core has already chosen a codec by media
type. application/octet-stream writes therefore still land in the
Modbus-oriented codec and cannot be intercepted. That is recorded as a
failing-by-design expectation rather than papered over, and needs the
core change discussed in Dec 2025.

174 passing.
Adds the remaining numeric scalars, the non-numeric built-ins (DateTime,
Guid, NodeId, QualifiedName, StatusCode, XmlElement), enumerations, the
null value, more array shapes and 2-dimensional matrices.

Matrices are the reason for the arrayDimensions field: OPC UA sends a
flat array plus a separate Dimensions member, which no value-shaped
contentType can carry.
A ByteString read as application/octet-stream came back as a garbled
string, and such a write never reached the binding at all: core used the
generic OctetstreamCodec, which packs Modbus registers. With core eclipse-thingweb#1572
the binding registers its own codec for the opc.tcp scheme, so both
directions stay OPC UA aware and every other binding is untouched.

The value is base64, as the JSON flavours already report for a
ByteString, and arrayBuffer() still returns the raw bytes. An array of
ByteStrings is refused: a raw octet stream cannot frame several.
Explains what the binding does and why: the two converters a value
crosses, what each contentType produces, the measured behaviour per OPC
UA data type, the essence of OPC UA JSON, and the decisions with their
costs. Diagrams are Mermaid inline, with PlantUML sources beside them.

Written for reviewers and for whoever maintains this next: the choices
that differ from OPC 10101 or from Part 6 Table 80 are stated as such,
with the reason and what it costs.
erossignon and others added 12 commits October 23, 2026 18:44
A 1.05 StatusCode is {"Code":...}, plus "Symbol" in Verbose (Part 6
5.4.2.12). The 1.05 schema allowed only an integer, so any 1.05
DataValue with a status other than Good was refused with "Invalid JSON
dataValue". node-opcua 2.186 and earlier wrote that field as
StatusCode, never UaStatus, so StatusCode replaces UaStatus.
With node-opcua 2.187.10 the 1.05 encodings name a namespace by its
URI (nsu=) when given the table, so a payload no longer depends on the
server's namespace indexes. Every encoder and decoder of the binding
now gets the table of its session.

The codecs that decode in core have no session: opcua+json and
octet-stream reads now carry ;uaconn= to the session's context, which
holds the table and the structure definitions. On write, opcua+json
passes the consumer's JSON through instead of decoding it with no
table, which turned an nsu= NodeId into a String NodeId of namespace 0.
The plan's appendix lists what the move to 2.187.10 did in the
binding: the namespace table, the schemas, the 1.05 status object and
the stricter Call check. The JSON pages show the new matrix shapes and
a structure's fields beside UaTypeId instead of UaBody.
The plan's question A3 and the error a structure write gets now point
at eclipse-thingweb/node-wot issue 1583, which carries the proposal
past this PR: structures given as OPC UA JSON 1.05, encoded by the
binding with its session.
The media type of OPC 10000-6 Table 80, planned for WoT OPC UA
Binding 1.1, as asked on PR 1574. The payload stays the UA Binary
DataValue; a type parameter picks what value() returns: Value (the
default), Variant or DataValue, as OPC UA JSON, so the timestamps,
status, type and Union choice the value alone loses stay reachable.

application/json, opcua+json, opcua+octet-stream, octet-stream and
an omitted contentType are refused, each naming its replacement.
The codec needs no URI scheme; OpcuaJSONCodec, OpcuaBinaryCodec and
the JSON schemas are removed.

BREAKING CHANGE: an opc.tcp form needs application/opcua+uabinary.
bindings:opcua:1 and :2 still referred to opcua-demo1.js and
opcua-demo2.js, renamed to demo-opcua1.js and demo-opcua2.js by the
kebab-case rename; both scripts failed with "Cannot find module".

Adds bindings:opcua:secure for the security demo, which had no script.
brewCoffee and fillTank passed the CoffeeMachineA object as the OPC UA
objectId, but MakeCoffee and FillTank are components of its 2:MethodSet,
not of the machine. The server rejected the pairing with
BadMethodInvalid, after resolving the method node itself just fine.

Point opcua:nodeId at 2:MethodSet in both action forms.
The temperature property copied the unit of pumpSpeed, and demo-opcua2
printed it. The demo server reports values around 27, i.e. degrees.
Describes what each of the four demos shows, how to run it (including
the test server the secure demo needs), and the two TD idioms readers
trip on: browse paths, and the method owner in an action form.
Output samples come from real runs; images are hand-made SVGs.
Written in ASD-STE100 Simplified Technical English.
The 15 opc.tcp forms of the OPC UA examples: 14 had no contentType
and one said application/json, which the binding now refuses.
D8 replaces D2, D3, D5, D6 and D7, which stay for the record. The
content negotiation and JSON pages describe the three views; the
octet-stream plan is marked superseded.
The views of application/opcua+uabinary each use one OPC UA JSON
edition, so the edition and mode fields of ContentFormat, and the
1.04 Reversible and 1.05 Verbose encoders behind them, were unreachable.

Value stays 1.04 NonReversible: 1.05 has no bare value and flattens a
matrix. Variant and DataValue are 1.05 Compact. Writes still accept
either edition. D8 and the encoding page say so.
@erossignon
erossignon force-pushed the wip/octet-stream-e2e branch from 3cfb484 to fb9a781 Compare October 23, 2026 17:18
Review of PR 1574. A write is converted to the target's DataType or
refused, never written approximately: integers out of range or with a
fraction, texts that are not numbers or dates, nested arrays in the
Value view, and Variant/DataValue JSON that decodes to no value were
written as wrapped, truncated, NaN or Null values. DateTime texts now
write back, and an abstract DataType takes the type of the value.

A refused contentType now fails before Read, Call or CreateMonitoredItems,
and a payload is read as JSON first: a JSON number looked like UA Binary.
The connection context keeps its structure definitions across calls.
@danielpeintner

danielpeintner commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

I black-box tested the changes:

  • "contentType": "application/opcua+uabinary" still doesn't work. I am getting Error: binding-opcua: unsupported contentType 'application/opcua+uabinary'. Supported: application/json, application/opcua+json;type=Value|Variant|DataValue (optionally ;version=1.05[;mode=compact|verbose]), application/opcua+octet-stream, and application/octet-stream (OPC UA Binary).
    THIS should be the contentType we want to use
  • "contentType": "application/octet-stream" .. works. I think we agreed to no longer support that?
  • "contentType:" "application/opcua+octet-stream" ... still works also !? reports the wrapped value
  • "contentType": "application/json" ... still reports the typed value
  • "contentType": "application/opcua+json" ... still works also !? reports the wrapped value

@sebastiankb mentions in #1574 (comment) he would like to use application/opcua+uabinary
@sebastiankb do we still want to support all these contentType's

I am fine having the application/opcua+uabinary;type=Value|Variant|DataValue and selecting one. Are others fine as well?

@sebastiankb

Copy link
Copy Markdown
Contributor

do we still want to support all these contentType's

exactly, only application/opcua+uabinary is needed

application/opcua+uabinary;type=Value|Variant|DataValue

I like the idea. If only application/opcua+uabinary is used (as default), it should act as application/opcua+uabinary;type=Value

@relu91

relu91 commented Oct 26, 2026

Copy link
Copy Markdown
Member

@relu91

* `application/opcua+octet-stream` is my preferred solution too, as I'm still not convinced about [feat(core): register codecs per URI scheme (completes #1412) #1572](https://github.com/eclipse-thingweb/node-wot/pull/1572), which included a mechanism only used as a workaround for this OPC UA binding in the core.

I hope, it is also ok to use application/opcua+uabinary instead, since you find this also in the UA spec here: https://reference.opcfoundation.org/specs/OPC-10000-6/7.4.2

No problem at all; I didn't hope we could use application/opcua+uabinary . Let's go in this direction.

@erossignon

Copy link
Copy Markdown
Contributor Author

@danielpeintner I think you tested old code again. This time it is not your mistake.

What happened

GitHub did not update the pull request ref after my push of Oct 23. refs/pull/1574/head still points to commit 3cfb4842 (Oct 20). The PR head is commit fb9a7818 (Oct 23). My instructions of Oct 2 use git fetch origin pull/1574/head, so they now give the old code.

You can see it with:

git ls-remote https://github.com/eclipse-thingweb/node-wot.git refs/pull/1574/head
3cfb484200f9b5ab9e1aeba8536aba9c985870f0        refs/pull/1574/head

Your error message comes from the old code. It says "Supported: application/json, application/opcua+json…". The PR head says "Supported: application/opcua+uabinary, with an optional ;type=Value|Variant|DataValue."

What the PR head does

  • application/opcua+uabinary is the only contentType. Without ;type=, it means ;type=Value.
  • ;type=Variant and ;type=DataValue give the Variant or the full DataValue.
  • The four old contentTypes give an error. The error tells you which contentType to use.
  • A form without contentType gives an error. TD 1.1 reads it as application/json.

How to test it

  1. Make an empty folder.
  2. Save the two scripts below in it: make-black-box.js and black-box.js.
  3. Run node make-black-box.js.

The script clones the branch (not the pull ref), checks out fb9a7818, builds and packs core and binding-opcua, installs only the two packs in a new project, and reads PumpSpeed from opcuademo.sterfive.com with each contentType. It writes the result to evidence-fb9a7818.txt.

Line 2 of the output tells you which code is installed: PR head or OLD CODE.

Note: the build uses npm ci --ignore-scripts. Without it, node-mbus (M-Bus binding) fails to build on Windows without Visual Studio. OPC UA does not need it.

make-black-box.js
// make-black-box.js: black-box test of PR 1574 from a fresh clone, Windows or Linux.
//
//   node make-black-box.js [commit]          (default commit: fb9a7818)
//
// In the current folder it creates:
//   node-wot/       the PR branch at <commit>, built and packed
//   opcua-test/     a project that installs only the two tarballs, with black-box.js
//   evidence-<commit>.txt
const { execSync } = require("child_process");
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");

const REPO = "https://github.com/node-opcua/node-wot.git";
const BRANCH = "wip/octet-stream-e2e";
const COMMIT = process.argv[2] || "fb9a7818";

const root = process.cwd();
const src = path.join(root, "node-wot");
const test = path.join(root, "opcua-test");
const log = [];

function sh(cmd, cwd) {
    // relative paths only: the evidence is meant to be posted
    const shown = cmd.split(root + path.sep).join("");
    console.log(`> ${shown}`);
    log.push(`> ${shown}`);
    // npm ci output is long; only the commands go to the evidence
    return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "inherit"], encoding: "utf8" });
}

function sha256(file) {
    return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
}

// 1. the PR code, from the branch and not from refs/pull/1574/head (stale since Oct 23)
if (fs.existsSync(src)) {
    throw new Error(`${src} exists, start from an empty folder`);
}
sh(`git clone -q -b ${BRANCH} ${REPO} node-wot`, root);
sh(`git -c advice.detachedHead=false checkout -q ${COMMIT}`, src);
const sha = sh("git rev-parse HEAD", src).trim();
// --ignore-scripts: node-mbus (M-Bus binding) needs Visual Studio on Windows; OPC UA does not
sh("npm ci --ignore-scripts --no-audit --no-fund", src);
sh("npx tsc -b packages/binding-opcua", src);
const packed = sh("npm pack --workspace @node-wot/core --workspace @node-wot/binding-opcua", src)
    .trim()
    .split(/\r?\n/)
    .map((name) => path.join(src, name));

// 2. a consumer project that knows nothing but the two tarballs
fs.mkdirSync(test);
fs.writeFileSync(path.join(test, "package.json"), JSON.stringify({ name: "opcua-test", private: true }, null, 2));
sh(`npm install --no-audit --no-fund ${packed.map((p) => `"${p}"`).join(" ")}`, test);
fs.copyFileSync(path.join(__dirname, "black-box.js"), path.join(test, "black-box.js"));

// 3. run it
const output = sh("node black-box.js", test);
console.log(output);

const evidence = [
    `PR 1574 black-box test, ${new Date().toISOString()}`,
    `repository: ${REPO}  branch: ${BRANCH}`,
    `commit:     ${sha}`,
    `node ${process.version}, npm ${execSync("npm -v", { encoding: "utf8" }).trim()}, ${process.platform}`,
    "",
    "tarballs (sha256):",
    ...packed.map((p) => `  ${sha256(p)}  ${path.basename(p)}`),
    "",
    "commands:",
    ...log.map((l) => `  ${l}`),
    "",
    "output of node black-box.js:",
    output.trimEnd(),
    "",
].join("\n");
const file = path.join(root, `evidence-${sha.slice(0, 8)}.txt`);
fs.writeFileSync(file, evidence);
console.log(`evidence written to ${file}`);
black-box.js
// black-box.js: reads PumpSpeed once per contentType, with the client of PR 1574 (2026-10-02)
const fs = require("fs");
const path = require("path");
const { Servient } = require("@node-wot/core");
const { OPCUAClientFactory } = require("@node-wot/binding-opcua");

const HREF = "opc.tcp://opcuademo.sterfive.com:26543?id=ns=1;s=PumpSpeed";

const CASES = [
    ["application/opcua+uabinary", "number"],
    ["application/opcua+uabinary;type=Value", "number"],
    ["application/opcua+uabinary;type=Variant", "object"],
    ["application/opcua+uabinary;type=DataValue", "object"],
    ["application/octet-stream", "number"],
    ["application/opcua+octet-stream", "object"],
    ["application/json", "number"],
    ["application/opcua+json", "object"],
    [undefined, "number"],
];

function installedCode() {
    const dist = path.dirname(require.resolve("@node-wot/binding-opcua"));
    const file = path.join(dist, "opcua-content-negotiation.js");
    const text = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
    return text.includes("application/opcua+uabinary")
        ? "PR head (Oct 23 or later): application/opcua+uabinary is known"
        : "OLD CODE (before Oct 23): application/opcua+uabinary is unknown, results below are not the PR";
}

function thingDescription(contentType, type) {
    const form = { href: HREF, op: ["readproperty", "observeproperty"] };
    if (contentType !== undefined) form.contentType = contentType;
    return {
        "@context": "https://www.w3.org/2019/wot/td/v1",
        "@type": ["Thing"],
        securityDefinitions: { nosec_sc: { scheme: "nosec" } },
        security: "nosec_sc",
        properties: { pumpSpeed: { description: "the pump speed", type, forms: [form] } },
    };
}

async function main() {
    console.log(`### ${new Date().toISOString()}  node ${process.version}`);
    console.log(`### installed @node-wot/binding-opcua: ${installedCode()}`);

    const servient = new Servient();
    servient.addClientFactory(new OPCUAClientFactory());
    const WoT = await servient.start();

    for (const [contentType, type] of CASES) {
        console.log(`\n----- contentType: ${contentType ?? "(none)"}   (TD type: ${type})`);
        try {
            const thing = await WoT.consume(thingDescription(contentType, type));
            const output = await thing.readProperty("pumpSpeed");
            console.log("pumpSpeed value is: ", await output.value());
        } catch (err) {
            console.log("Script error:", err.message);
        }
    }
    await servient.shutdown();
    process.exit(0);
}

main().catch((err) => {
    console.error("Start error:", err);
    process.exit(1);
});

Our result

evidence-fb9a7818.txt (Windows, node 22)
PR 1574 black-box test, 2026-10-26T16:26:24.727Z
repository: https://github.com/node-opcua/node-wot.git  branch: wip/octet-stream-e2e
commit:     fb9a7818af943cf0b7a2f6d6e71785a4ca6acca6
node v22.23.4, npm 10.9.9, win32

tarballs (sha256):
  bf0bae5cf0770b1eb3fccc21933d3fb24469fa1f55bc133b861ba052192807d3  node-wot-core-1.0.0-SNAPSHOT.0.tgz
  8f1dd9557a41f0dedbd4a00664bff5c38e990de91c99703e6cfc4bb43e492e5d  node-wot-binding-opcua-1.0.0-SNAPSHOT.0.tgz

commands:
  > git clone -q -b wip/octet-stream-e2e https://github.com/node-opcua/node-wot.git node-wot
  > git -c advice.detachedHead=false checkout -q fb9a7818
  > git rev-parse HEAD
  > npm ci --ignore-scripts --no-audit --no-fund
  > npx tsc -b packages/binding-opcua
  > npm pack --workspace @node-wot/core --workspace @node-wot/binding-opcua
  > npm install --no-audit --no-fund "node-wot\node-wot-core-1.0.0-SNAPSHOT.0.tgz" "node-wot\node-wot-binding-opcua-1.0.0-SNAPSHOT.0.tgz"
  > node black-box.js

output of node black-box.js:
### 2026-10-26T16:26:23.611Z  node v22.23.4
### installed @node-wot/binding-opcua: PR head (Oct 23 or later): application/opcua+uabinary is known

----- contentType: application/opcua+uabinary   (TD type: number)
pumpSpeed value is:  278.68100912504235

----- contentType: application/opcua+uabinary;type=Value   (TD type: number)
pumpSpeed value is:  278.5078870294452

----- contentType: application/opcua+uabinary;type=Variant   (TD type: object)
pumpSpeed value is:  { UaType: 11, Value: 278.3652169729509 }

----- contentType: application/opcua+uabinary;type=DataValue   (TD type: object)
pumpSpeed value is:  {
  UaType: 11,
  Value: 274.93788229420466,
  ServerPicoseconds: 661792000,
  SourcePicoseconds: 661792000,
  ServerTimestamp: '2026-10-26T16:26:25.736Z',
  SourceTimestamp: '2026-10-26T16:26:25.736Z'
}

----- contentType: application/octet-stream   (TD type: number)
Script error: binding-opcua: contentType 'application/octet-stream' is not supported on opc.tcp forms. Use 'application/opcua+uabinary'.

----- contentType: application/opcua+octet-stream   (TD type: object)
Script error: binding-opcua: contentType 'application/opcua+octet-stream' is not supported on opc.tcp forms. Use 'application/opcua+uabinary;type=DataValue'.

----- contentType: application/json   (TD type: number)
Script error: binding-opcua: contentType 'application/json' is not supported on opc.tcp forms. Use 'application/opcua+uabinary'.

----- contentType: application/opcua+json   (TD type: object)
Script error: binding-opcua: contentType 'application/opcua+json' is not supported on opc.tcp forms. Use 'application/opcua+uabinary;type=DataValue'.

----- contentType: (none)   (TD type: number)
Script error: binding-opcua: an opc.tcp form needs contentType 'application/opcua+uabinary'. Without one, a form is application/json (W3C WoT TD 1.1 section 5.3.4.2), which this binding does not support.

@danielpeintner

Copy link
Copy Markdown
Member

GitHub did not update the pull request ref after my push of Oct 23. refs/pull/1574/head still points to commit 3cfb4842 (Oct 20). The PR head is commit fb9a7818 (Oct 23). My instructions of Oct 2 use git fetch origin pull/1574/head, so they now give the old code.

VERY strange and also very annoying since a simple local PR checkout points to a previous commit. Something like this did not happen to me before. Does anyone have an idea what caused it?

Anyhow, blackbox testing looks fine now. Please give me some time to look at your code.

@danielpeintner danielpeintner left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sebastiankb @relu91 @egekorkan please comment if you have another opinion

Comment thread packages/binding-opcua/doc/decisions.md Resolved
Comment thread packages/binding-opcua/doc/README.md Resolved
Comment thread packages/binding-opcua/src/factory.ts Resolved
Review of PR 1574. The old contentTypes are refused with the generic
"unsupported contentType" error instead of naming a replacement:
application/opcua+uabinary does not replace JSON, they were never right.

doc/ is removed: it recorded decisions and plans that no longer hold.
What still holds moves to the README contentType section: the three
views, writes, actions, what the Value view loses, structure writes
(issue 1583). Comments pointing at doc/ or at plan questions now say
the rule itself. The removed pages stay readable at the tag
archive/pr-1574-design-notes of node-opcua/node-wot.
@erossignon

Copy link
Copy Markdown
Contributor Author

Two new commits:

  • 84c6fbc: a write is converted to the node's DataType or refused, never written approximately; a refused contentType fails before any Read, Call or monitored item.
  • eaad6a6: your review. doc/ removed, no replacement hints, README describes the current settings only.

Black-box test of eaad6a6, same scripts as before (node make-black-box.js eaad6a6f):

evidence-eaad6a6f.txt
PR 1574 black-box test, 2026-10-28T22:23:25.080Z
repository: https://github.com/node-opcua/node-wot.git  branch: wip/octet-stream-e2e
commit:     eaad6a6f241d828fd41be9b44c21a2d2a8448793
node v22.23.4, npm 10.9.9, win32

tarballs (sha256):
  bf0bae5cf0770b1eb3fccc21933d3fb24469fa1f55bc133b861ba052192807d3  node-wot-core-1.0.0-SNAPSHOT.0.tgz
  dc60e6b43451333dc623864d2f685ad2780d7a14ca352b2f4a30b5d6a9a5413e  node-wot-binding-opcua-1.0.0-SNAPSHOT.0.tgz

commands:
  > git clone -q -b wip/octet-stream-e2e https://github.com/node-opcua/node-wot.git node-wot
  > git -c advice.detachedHead=false checkout -q eaad6a6f
  > git rev-parse HEAD
  > npm ci --ignore-scripts --no-audit --no-fund
  > npx tsc -b packages/binding-opcua
  > npm pack --workspace @node-wot/core --workspace @node-wot/binding-opcua
  > npm install --no-audit --no-fund "node-wot\node-wot-core-1.0.0-SNAPSHOT.0.tgz" "node-wot\node-wot-binding-opcua-1.0.0-SNAPSHOT.0.tgz"
  > node black-box.js

output of node black-box.js:
### 2026-10-28T22:23:24.662Z  node v22.23.4
### installed @node-wot/binding-opcua: PR head (Oct 23 or later): application/opcua+uabinary is known

----- contentType: application/opcua+uabinary   (TD type: number)
pumpSpeed value is:  223.92641568415723

----- contentType: application/opcua+uabinary;type=Value   (TD type: number)
pumpSpeed value is:  223.4989669210036

----- contentType: application/opcua+uabinary;type=Variant   (TD type: object)
pumpSpeed value is:  { UaType: 11, Value: 223.31424334299746 }

----- contentType: application/opcua+uabinary;type=DataValue   (TD type: object)
pumpSpeed value is:  {
  UaType: 11,
  Value: 223.15862301594112,
  ServerPicoseconds: 536362000,
  SourcePicoseconds: 536362000,
  ServerTimestamp: '2026-10-28T22:23:25.128Z',
  SourceTimestamp: '2026-10-28T22:23:25.128Z'
}

----- contentType: application/octet-stream   (TD type: number)
Script error: binding-opcua: unsupported contentType 'application/octet-stream'. Supported: application/opcua+uabinary, with an optional ;type=Value|Variant|DataValue.

----- contentType: application/opcua+octet-stream   (TD type: object)
Script error: binding-opcua: unsupported contentType 'application/opcua+octet-stream'. Supported: application/opcua+uabinary, with an optional ;type=Value|Variant|DataValue.

----- contentType: application/json   (TD type: number)
Script error: binding-opcua: unsupported contentType 'application/json'. Supported: application/opcua+uabinary, with an optional ;type=Value|Variant|DataValue.

----- contentType: application/opcua+json   (TD type: object)
Script error: binding-opcua: unsupported contentType 'application/opcua+json'. Supported: application/opcua+uabinary, with an optional ;type=Value|Variant|DataValue.

----- contentType: (none)   (TD type: number)
Script error: binding-opcua: an opc.tcp form needs contentType 'application/opcua+uabinary'. Without one, a form is application/json (W3C WoT TD 1.1 section 5.3.4.2), which this binding does not support.

About the stale ref: I don't know the cause. GitHub resynced refs/pull/1574/head with today's push; it now points to eaad6a6.

@danielpeintner danielpeintner left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me now.

I have one minor comment which we might want to address...

Comment thread packages/binding-opcua/README.md Resolved
Review of PR 1574. The code accepts the explicit form, and only the
README left it out.
@danielpeintner

Copy link
Copy Markdown
Member

Checked offline with @sebastiankb. He is fine too.

@danielpeintner
danielpeintner merged commit 57601f1 into eclipse-thingweb:master Nov 5, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ContentType seems to confuse OPC UA

5 participants