Skip to content

The Node/TypeScript client API

What @opensysml/client exports, how the two entry points differ, and where its v1 surface stops. Choosing between the clients is client libraries; using one as a task is guide chapter 9. The client's own notes on packaging and its conformance run are in clients/node/README.md.

npm install @opensysml/client        # once the first release is published

Nothing is published yet, so a checkout builds it: npm install && npm run build in clients/node.

The two entry points

import reaches private child service
@opensysml/client Node, over Connect with protobuf bodies yes, by default
@opensysml/client/browser a page, over fetch (@connectrpc/connect-web) no — a browser spawns nothing

Both re-export the isomorphic core; the browser entry point requires an address, since there is nothing to fall back to.

Opening a connection

import { connect, load, loads } from "@opensysml/client";

await using connection = await connect();          // private child of this process
const model = await connection.loads("package Demo { part def Car; }");

load(path) and loads(source) are the one-shot forms: each opens a connection of its own and closes it with the model. connect() is the longer-lived form, and every model parsed over it shares one service and one parse cache. Connection and Model both implement Symbol.asyncDispose, so await using closes them; close() is the explicit form and is safe to call twice.

ConnectOptions extends TransportOptions:

option effect
address host:port or a URL of a service to use; without it, a private child is started
encoding "protobuf" (default) or "json"; JSON costs ~6x the service's CPU on large answers
protocol "connect" (default) or "grpc"; { protocol: "grpc", encoding: "json" } is refused, not downgraded
timeoutMs deadline applied to every call the connection makes
headers extra headers sent with every call
onResponse called with each response, for logging, metrics or a conformance runner

$OPENSYSML_SERVICE=host:port is the environment form of address. A connection to a service this client did not start is only disconnected from on close(), never stopped.

Reading a model

const model = await connection.load("model.sysml");
model.hash;                                  // what the service holds it under
model.diagnostics;                           // in the order the service reported them
model.hasErrors;                             // any error-severity diagnostic

const car = await model.symbol("Demo::Car"); // short name, FQN or id
await car.children();                        // its members, one call each
for await (const symbol of model.walk()) {}  // breadth-first from the root

const value = await model.eval("2 + 2");                       // SysMLValue
const mass = await model.eval("mass", { subject: "Demo::sedan" });
const tree = await model.instantiate("Demo::Car");
tree.get("wheels");                          // a FeatureValue of the root object
tree.byId(id);                               // any object the instantiation produced

connection.model(hash) adopts a model the service already holds, which lets a hash pass between processes; an adopted model has no root symbol, so symbols are looked up by qualified name, and the service answers NOT_FOUND once it has evicted the model. symbol() searches breadth-first when given a short name and raises SymbolNotFoundError naming near misses; symbolById() is the single call for a name the service can resolve directly.

ParseOptions are language ("sysml" or "kerml", for inline content) and strict; both are capability-gated, and the client checks before it calls.

Values are discriminated unions

Every oneof the service answers with arrives as a union to switch on, rather than a message with optional fields:

switch (value.kind) {
  case "int":      value.value;                  // bigint, never lossy
  case "real":     value.value;                  // number
  case "boolean":
  case "string":   value.value;
  case "quantity": value.magnitude; value.unit;
  case "enum":     value.value.name;             // and its literal/enumeration ids
  case "instance": value.id;                     // an object in the same tree
  case "sequence": value.elements;               // SysMLValue[]
  case "null":     value.reason;                 // evaluated, no value
  case "unset":    break;                        // declared, never given one
  case "absent":   break;                        // the service sent no value at all
}

unset and absent are distinct on purpose: the first is a feature the model leaves without a value, the second a field the answer did not carry. SysMLVerdict (holds / fails / undecided) and FeatureValue (single / many / error) are unions of the same shape. Integers are bigint, because the service's int64 does not fit a number and an exact comparison would otherwise be a lie. decodeValue, decodeVerdict and formatValue are exported for a caller decoding a response it obtained itself.

Errors

Everything derives from OpenSysMLError, so the family can be caught without knowing its members.

error what happened
ServiceError the service could not be reached, started, or answered nothing usable
ServiceStartError a private child failed to start, or died while it was needed
ClosedConnectionError the connection was closed and cannot be used again
ParseError a file could not be read, or its content did not parse; carries diagnostics
EvaluationError the call succeeded and the answer reports a model failure
SymbolNotFoundError the model declares no such symbol
MissingCapabilityError the service does not advertise a capability the call needs
DownloadError a release binary could not be downloaded or installed
ChecksumMismatchError a download's digest contradicts the one expected of it
UnpinnedReleaseError / UnsignedReleaseError / ManifestSignatureError nothing pins the release, nothing signs it, or a signature does not verify

The ParseError/EvaluationError split against ServiceError is the one the conformance suite draws: an expression that will not evaluate is a successful call carrying an error, not a service problem.

Capability negotiation

Clients negotiate on the capability names GetServerInfo reports, never on the version string:

import { CAPABILITY_EVALUATE_SUBJECT } from "@opensysml/client";

if (connection.info.has(CAPABILITY_EVALUATE_SUBJECT)) {
  await model.eval("mass", { subject: "Demo::sedan" });
}

The client checks the advertised list before making a gated call, so MissingCapabilityError names the service, its version and how to get one that has the capability, without a round trip. A service too old to answer GetServerInfo at all is still usable: connection.info.answered is false and its capability list is empty. Capabilities that only describe how a response is populated omit the fields they name rather than refusing the call.

The service binary

The binary comes from an optional per-platform npm package (@opensysml/sysml-grpc-{linux-x64,linux-arm64,darwin-x64,darwin-arm64,win32-x64}), selected by npm from its os/cpu metadata, with no postinstall script. Resolution order is $OPENSYSML_BINARY, that package, ~/.opensysml/bin/sysml-grpc (the cache the Python, Java and Rust clients share), then sysml-grpc on $PATH. resolveBinary() downloads a release into that cache when nothing above resolved, verifying it against the digests pinned in the published release-digests.json, else against the release's sigstore-signed SHA256SUMS.txt; a release neither pins nor signs is refused unless $OPENSYSML_ALLOW_UNPINNED_DOWNLOAD accepts same-origin trust explicitly. clients/node/README.md documents the download and its trust model in full.

A private child is started with -port 0 -health-port 0 -report-address -exit-with-parent, and the client reads the address from its first stdout line, so no port is chosen, probed or retried. One child serves every connection of a thread — a worker_threads worker gets its own — and stops when the last connection closes. It cannot be orphaned: the client holds the write end of the child's stdin and never writes to it, so the kernel closes that pipe however this process dies, SIGKILL included, and the child exits at end of file.

In a browser

import { connect } from "@opensysml/client/browser";

await using connection = await connect({ address: "https://sysml.example.com" });

Two limits to plan for: the service must allow the page's exact origin (-cors-allowed-origins https://app.example.com, never *) and be served over TLS for an HTTPS page to reach it; and connect-go does not implement the base64 grpc-web-text variant, which this fetch-based client does not need but a grpc-web client requiring -text would.

What v1 does not do

The ergonomic layer covers GetServerInfo, ParseFile, GetSymbol, Evaluate and Instantiate. Deliberately absent, rather than half-implemented: generated model-ergonomics types, the edit API (ApplyEdits), RDF conversion (Convert), the verification helpers, Query, GetDiagnostics, EvaluateCalc, ExecuteAction and ExecuteState. The service serves all of them; connection.rpc is the escape hatch, being the generated Connect client, and SysMLService is exported for a caller building its own.

Conformance

npm run conformance -- --allow-skips --report report.json runs the language-neutral suite through the public API, and emits the report shape cmd/conformance emits. 59 scenarios per protocol over grpc, connect and connect-json: 23 pass and 36 are skipped, being the 35 scenarios of the RPCs above plus one the public API cannot express (a ParseFile naming no source). --mutate <name> corrupts a response on its way through the client and each mutation must make a scenario fail, which is what keeps the run from being vacuous.