8.6 KiB
Runtime NodeSets
This guide explains how to load one or more NodeSet2 XML documents into the server's address space at startup without writing a source-generated or hand-coded NodeManager. You configure which files or streams to load; the server imports them in dependency order and registers the resulting nodes.
When to use the runtime NodeSet path
Use AddRuntimeNodeSet when:
- You receive a NodeSet2 XML from a companion-specification vendor and want to host its nodes without regenerating source.
- Your information model changes frequently enough that rebuilding the source-generated manager for every XML update would be disruptive.
- You are prototyping or testing a new NodeSet2 design.
Use the source-generated path when you want compile-time safety, strong typing, and AOT-safe named constants for every node in your model. The runtime path gives you generic NodeState objects and untyped browse-path wiring.
Startup-only semantics
The runtime NodeSet loader runs once during server startup, inside CreateAddressSpaceAsync. There is no mechanism to hot-reload, add, or remove NodeSets after the server has accepted its first client connection. If you need to pick up changes to the NodeSet XML, restart the server.
Quick-start examples
Single file
services.AddOpcUa()
.AddServer(o => { /* … */ })
.AddRuntimeNodeSet("Models/MyMachine.NodeSet2.xml");
Single file with a fluent callback
Wire read/write/method handlers on top of the imported nodes using the existing untyped INodeManagerBuilder surface:
services.AddOpcUa()
.AddServer(o => { /* … */ })
.AddRuntimeNodeSet(
"Models/MyMachine.NodeSet2.xml",
nodes =>
{
nodes.Node("Machines/Machine1/Start")
.OnCall(StartMachineAsync);
nodes.Variable<double>("Machines/Machine1/Temperature")
.OnRead(ReadTemperature);
});
Group of dependent NodeSets
Register multiple NodeSet2 sources that depend on each other. The factory resolves the import order automatically from the RequiredModel declarations in each document.
services.AddOpcUa()
.AddServer(o => { /* … */ })
.AddRuntimeNodeSet(options =>
{
options.Sources =
[
RuntimeNodeSetSource.FromFile("Models/Opc.Ua.Di.NodeSet2.xml"),
RuntimeNodeSetSource.FromFile("Models/MyMachine.NodeSet2.xml")
];
options.DefaultNamespaceUri = "urn:example:MyMachine";
options.Configure = nodes =>
{
nodes.Node("Machines/Machine1/Start").OnCall(StartMachineAsync);
};
});
Custom stream source
Use RuntimeNodeSetSource.FromStream when you want to open the NodeSet2 document lazily at server startup — for example from a database, a blob store, or an assembly resource. The delegate is called once during startup and must return a fresh, readable Stream each time.
services.AddOpcUa()
.AddServer(o => { /* … */ })
.AddRuntimeNodeSet(options =>
{
options.Sources =
[
RuntimeNodeSetSource.FromStream(
name: "MyMachine",
openStream: ct => OpenNodeSetStreamAsync(ct),
modelNamespaceUris: ["urn:example:MyMachine"])
];
});
Direct factory registration
If you prefer to create the factory manually — for example to inject it into an existing StandardServer subclass — construct a RuntimeNodeSetNodeManagerFactory directly:
var factory = new RuntimeNodeSetNodeManagerFactory(new RuntimeNodeSetOptions
{
Sources = [RuntimeNodeSetSource.FromFile("Models/MyMachine.NodeSet2.xml")]
});
// In a StandardServer subclass constructor:
AddNodeManager(factory);
Stream ownership contract
When FromStream is used, the runtime loader calls the openStream delegate once while the NodeManager factory is created during server startup and closes the returned stream after deserialization. You must ensure that:
- Each call to
openStreamreturns a new stream positioned at the beginning. - The stream is readable and contains a valid NodeSet2 XML document.
- You do not close the stream yourself; the factory disposes it after
UANodeSet.Read.
If the delegate returns null or the stream does not contain valid NodeSet2 XML, server startup fails with a clear InvalidOperationException that names the source.
Default namespace for unqualified browse paths
When the Configure callback uses browse paths without an explicit ns=N; prefix (for example "Machines/Machine1/Start"), the runtime loader must know which namespace index to apply for the first path segment. The resolution is:
- If
RuntimeNodeSetOptions.DefaultNamespaceUriis set, that URI is used. - Otherwise the factory infers the unique leaf model — the one model in the loaded group that is not required by any other included source.
- If inference is ambiguous (multiple leaf models and a
Configurecallback is present), startup fails with an error message that lists the candidates. In this case, setDefaultNamespaceUriexplicitly.
When no Configure callback is registered, DefaultNamespaceUri has no effect and may be omitted.
Dependency sorting
The factory reads the Models/Model/RequiredModel entries from each parsed NodeSet document and performs a topological sort (Kahn's algorithm) before importing. Import order guarantees that a required model's nodes are in the address space before any document that depends on them imports its nodes.
Dependencies on models not included in the group — for example the OPC UA base namespace or a third-party model hosted by a generated NodeManager — are silently allowed and treated as external. The server resolves cross-manager references through the normal AddReverseReferencesAsync mechanism.
Cycles among the included sources cause InvalidOperationException at startup with an error message that lists the participating sources.
Complex types
Runtime complex type loading (structures, enumerations, union types) is on by default and requires no extra configuration. After all NodeManagers have built their address spaces, StandardServer.OnNodeManagerStartedAsync scans every DataType node whose DataTypeDefinition attribute is populated and registers a NativeAOT-safe stand-in encodeable in the server's factory. The same stand-ins are reused for client decode/encode via the OPC UA Part 6 binary protocol.
You can tune this behaviour through the existing ServerComplexTypeOptions:
services.AddOpcUa()
.AddServer(o => { /* … */ })
.AddRuntimeNodeSet("Models/MyMachine.NodeSet2.xml")
.AddComplexTypeSystem(o => o.Enabled = false); // disable if not needed
Setting StandardServer.LoadComplexTypes = false or disabling the complex type system entirely suppresses the stand-in loading without producing a warning. No second complex-type loading path is introduced by the runtime NodeSet feature.
For a complete description of the server-side complex type system, see ComplexTypes.md.
Comparison with source-generated NodeManagers
| Aspect | Runtime NodeSet (AddRuntimeNodeSet) |
Source-generated ([NodeManager]) |
|---|---|---|
| Node access in callbacks | Generic NodeState / BaseVariableState via untyped browse paths |
Strongly typed, compiler-checked fluent accessors per node |
| Compilation required on model change | No — reload the XML and restart | Yes — regenerate and rebuild |
| AOT / trimming compatibility | Full (uses the existing UANodeSet.Read XmlSerializer path) |
Full (generated code is static) |
| Named NodeId constants | Not generated | Generated (Variables.*, Objects.*, etc.) |
| Multiple namespaces in one manager | Yes — group multiple sources | One namespace per generator run |
| DI registration | AddRuntimeNodeSet(...) |
AddNodeManager<TFactory>() |
| Stream / file input | Files and custom stream factories | MSBuild AdditionalFiles only |
Source-generated managers are the recommended path for production code where type safety and compile-time validation matter. Runtime NodeSets are the recommended path for rapid prototyping, model-file delivery scenarios, and cases where the XML content changes independently of the server binary.
Related documentation
- Source-Generated NodeManagers — strongly typed alternative.
- Dependency Injection —
IOpcUaServerBuilderand service registration. - ComplexTypes.md — server-side complex type loading and client-side decoding.