Skip to content

Latest commit

 

History

History
315 lines (251 loc) · 17.9 KB

File metadata and controls

315 lines (251 loc) · 17.9 KB

WebAssembly builds

OpenSysML is Go, and Go compiles it for two WebAssembly targets. This page says what each is for, how to build and run them, what works in them, and what a WebAssembly host cannot do — with the message each limitation answers with, so a refusal is never mistaken for a defect.

Stable releases ship sysml-wasm.wasm with its matching wasm_exec.js, list both in the signed SHA256SUMS.txt, and cover them with SLSA provenance. Nightly snapshots list the same assets in their cosign-signed checksum manifest; like every nightly asset, they have no SLSA provenance. The npm package @openmbee/opensysml-wasm carries both assets. Other WebAssembly builds remain available from source for hosts that run modules rather than executables.

Building

make build-wasm          # both targets
make build-wasm-wasip1   # or one
make build-wasm-js

The output is one directory per target, seven commands each, stamped with the same version information a native build carries:

bin/wasm/wasip1/{sysml,sysml-lsp,sysml-grpc,sysml-engine,sysml-syntax,sysml-core,sysml-wasm}.wasm
bin/wasm/js/{sysml,sysml-lsp,sysml-grpc,sysml-engine,sysml-syntax,sysml-core,sysml-wasm}.wasm
bin/wasm/js/wasm_exec.js      # the runtime a browser page includes

wasm_exec.js is the library, not a runner: a page loads it with a <script> tag, and Node runs these modules with $(go env GOROOT)/lib/wasm/wasm_exec_node.js, which loads its own copy beside it.

make build is unchanged and stays native; a WebAssembly build is always asked for.

make build-engine, make build-core, make build-syntax and make build-sysml-wasm build the JSON commands natively into bin/, where each serves its JSON-RPC over standard input and output as its WASI build does. They are opt-in as well: make build and make install leave them out of the native executables; the combined JavaScript WebAssembly module is published separately.

make build-wasm-prod builds a smaller sysml-prod.wasm for each target with -tags sysml_prod (make build-prod is the native counterpart). It leaves out SysML v1 migration, repository sync, -compile, the HTML and PDF document forms, FMU import, profiling and the REPL's %features … json. A flag of a left-out group is hidden from -help and refused with status 2. Each group also has a tag of its own (sysml_nov1, sysml_nosync, sysml_nocodegen, sysml_nodocpdf, sysml_nofmi, sysml_noprofile, sysml_noreplext).

Running a WASI build

wasip1 binaries run under any WASI preview 1 runtime. Wasmtime is the one Go's own runner uses:

wasmtime run --dir=/ --env PWD="$PWD" bin/wasm/wasip1/sysml.wasm model.sysml -e 'total'

--dir=/ preopens the filesystem root, which is how the working directory becomes reachable: a runtime that preopens nothing can run -version, but can open no model. --env PWD="$PWD" is what makes model.sysml resolve where you are — a wasip1 program's os.Getwd is the PWD it was given and nothing else, so without it the path resolves against /.

The Go toolchain ships the same runner as a script, $(go env GOROOT)/lib/wasm/go_wasip1_wasm_exec: it opens /, supplies the working directory and environment each runtime needs, and uses wasmtime unless GOWASIRUNTIME names wasmer, wazero or wasmedge.

A host without one of those can use Node's WASI implementation directly:

// wasi.mjs — node wasi.mjs <binary.wasm> [args...]
import { WASI } from 'node:wasi';
import fs from 'node:fs';

const [binary, ...args] = process.argv.slice(2);
const wasi = new WASI({
  version: 'preview1',
  args: [binary, ...args],
  // PWD is what a wasip1 program resolves relative paths against; process.cwd()
  // keeps it right where the shell does not export it.
  env: { ...process.env, PWD: process.cwd() },
  preopens: { '/': '/' },   // a deployment opens only the directories it needs
  returnOnExit: true,
});
const module = await WebAssembly.compile(fs.readFileSync(binary));
const instance = await WebAssembly.instantiate(module, wasi.getImportObject());
process.exit(wasi.start(instance));

Node's WASI host cannot block waiting for a pipe: a read from one with nothing ready comes back EAGAIN rather than waiting. Drive its processes from a file, or from arguments — -version, -validate, -eval, -engines and the prompt itself all work that way — and use a runtime that reads pipes properly for anything that speaks a protocol over its standard input.

Running a js build

js binaries run through the toolchain's wasm_exec, under Node or in a browser:

node --stack-size=8192 --no-concurrent-sparkplug "$(go env GOROOT)/lib/wasm/wasm_exec_node.js" bin/wasm/js/sysml.wasm -version
# the same, with the stack size the runner itself sets:
"$(go env GOROOT)/lib/wasm/go_js_wasm_exec" bin/wasm/js/sysml.wasm -version

Three constraints apply to js builds:

  • argv and the environment share about 8 KiB. It writes both into linear memory at a fixed offset and refuses past wasmMinDataAddr; a large environment fails before main runs with total length of command line and environment variables exceeds limit. Run it with a small environment — PATH, HOME, TMPDIR are enough for these commands.
  • The stack needs raising. Node's default JavaScript stack is too small for deep model traversal; --stack-size=8192 is what Go's own runner passes.
  • Node can hang at exit. A command that exits right after starting (-version, say) occasionally prints its output and then never exits: Node deadlocks while shutting down if its background compiler is still running. --no-concurrent-sparkplug avoids it.

In a browser, nothing wires a page's input to the module's standard input: an embedder provides that itself. The commands take their input from arguments and files, so the parts that need no interactive stream work as they do under Node.

The execution engine

sysml-engine is the in-process half of the service: the execution RPCs — ParseSources, Evaluate, Instantiate, ExecuteAction, ExecuteState — answered with the same JSON sysml-grpc emits, but without the protobuf machinery, the analysis framework or a held-object store, which is what keeps a WebAssembly build small enough to embed in a page. A model parsed through it hashes to the same modelHash the service returns, and a request a service client encodes decodes identically here.

It also serves RenderView, an engine-only call that returns the drawing data for a declared view or a targeted pseudo-view. It is not an RPC of sysml-grpc. A request names the cached model and a declared view's qualified name or the target of a pseudo-view:

{
  "modelHash": "…",
  "view": "#interconnection:OpenSysMLStack::stack",
  "ports": "minimal"
}

ports is optional: empty or minimal includes only ports an interconnection edge ends at, and full includes every port. Each node's optional ports array has id, name, optional type and optional direction; undirected ports omit the direction. An edge's optional fromPort and toPort identify the endpoint ports by those IDs. The response also carries the view kind, stated rendering, notices, canvas, nodes, edges, and, for tables, columns and rows; its node and edge fields use the same JSON names as opensysml/render.

The engine has no current-document context, so a pseudo-view must name an element, for example #tree:OpenSysMLStack::stack. An untargeted #tree or #interconnection is refused with InvalidArgument and the supported pseudo-view spellings.

The js build installs a host surface instead of reading a pipe: load it through wasm_exec.js with no arguments and globalThis.sysmlEngine appears with

const answer = JSON.parse(sysmlEngine.call(method, paramsJSON));
sysmlEngine.version;

where call is synchronous — it runs the method on the JS thread and returns the JSON-RPC response envelope ({"jsonrpc":"2.0","id":null,"result":…} or …"error":{"code":…,"message":…}}) as a string. Pass -stdio and the js build serves the pipe instead, as the native and wasip1 builds always do: the same Content-Length frames and JSON-RPC 2.0 bodies sysml-grpc -transport stdio speaks, answered sequentially.

Measured on a go1.25 js/wasm build of this tree: about 27.7 MB of module, 6.9 MB gzipped, 4.9 MB under Brotli.

Requests decode the lowerCamelCase field names protojson and protobuf-es emit; the proto snake_case spellings protojson also accepts are not read. What it does not serve is refused rather than dropped: an exploring schedule is answered Unimplemented with exploration is not served by sysml-engine: exploring schedules are served by sysml-grpc, and every other method name — verification, document queries, tools — answers <Method> is not served by sysml-engine. Within the served methods: diagnostics are the parser's syntax diagnostics only, since the engine does not run the analysis tier; a ParseSources response carries no roots; and a frame whose Content-Type is a protobuf body is answered only application/json bodies are served. Tool-backed engines are the service's job too — the engine builds a runtime context directly, so behavior an external engine would compute stays on sysml-grpc.

The syntax service

sysml-syntax is the purely syntactic half: it answers Parse, Format and Tokens on content a request carries, with no name resolution and no standard library, which is what keeps it even smaller than the engine. Like the engine, the js build installs a host surface — globalThis.sysmlSyntax with the same synchronous call(method, paramsJSON) — and the same -stdio flag swaps it for the Content-Length-framed JSON-RPC 2.0 pipe the native and wasip1 builds always serve.

  • Parse takes {content, language} and answers {"diagnostics":[…]}: the parser's syntax errors, then its warnings, each as the Diagnostic message a ParseFile answer carries; a clean document answers {}.
  • Format takes {content, language, tolerateSyntaxErrors} and answers {content, diagnostics, error} as a sysml→sysml Convert does: the re-indented source, or — when the input has syntax errors and the request did not tolerate them — error with the converter's message and diagnostics listing them. It refuses invalid input unless tolerateSyntaxErrors is set.
  • Tokens takes {content, language} and answers {"legend":{…},"data":[…]}: the full LSP semantic-tokens legend and the relative-encoded token data for the document's lexical classes only — keywords, comments, strings and numbers. With no symbol table it emits no declaration/reference classes and no modifiers.

language is "sysml" or "kerml" (empty means SysML), inline content is named <content>, and every other method answers <Method> is not served by sysml-syntax. Being syntactic only, it reports no semantic diagnostics.

Measured on a go1.25 js/wasm build of this tree: about 5.0 MB of module, 1.32 MB gzipped, 0.97 MB under Brotli.

The validation core

sysml-core serves the model-validation surface of sysml-grpc without protobuf, Connect, or the execution runtime. It embeds the standard library, parses one or more SysML or KerML documents together, runs the validation passes on models that parse cleanly, and reports diagnostics and shared symbol facts.

  • ParseSources parses a set of inline or file-backed documents as one model and returns its hash, one root per document, and diagnostics.
  • ParseFile parses a single inline document or file and returns its hash, root and diagnostics.
  • GetDiagnostics returns the parser and validation diagnostics for a cached model.
  • GetSymbol returns the symbol's type, multiplicity, specialization and attribute facts.

The js build installs globalThis.sysmlCore with version and synchronous call(method, paramsJSON), which returns the JSON-RPC envelope string. Passing -stdio selects the same sequential, Content-Length-framed JSON-RPC pipe used by the native and wasip1 builds. Requests and responses use the lowerCamelCase protojson field names.

The core does not execute models or serve conversion, query, document, verification or tool methods. Those execution methods belong to sysml-engine; every other unsupported method belongs to sysml-grpc. Such calls answer Unimplemented with that routing guidance rather than being silently ignored. File-backed requests still require the host to make the requested paths readable; the standard library itself is embedded.

Measured on a go1.25 js/wasm build: 20,601,256 raw bytes, 5,444,030 bytes with gzip -9, and 3,843,951 bytes with Brotli.

The combined module

The Node and browser client adapter is documented in WebAssembly, without a service. Stable and nightly releases include the module and matching runtime; the npm package is @openmbee/opensysml-wasm.

sysml-wasm combines the parsing, validation and execution methods of sysml-core and sysml-engine in one WebAssembly module. It serves ParseSources, ParseFile, GetDiagnostics, GetSymbol, Evaluate, Instantiate, ExecuteAction, ExecuteState and GetServerInfo. Parsing and symbol facts route through the core; evaluation and execution route through the engine. ParseSources and ParseFile check that both frontends produce the same model hash and return the core response, including roots and diagnostics. It does not dispatch the engine-only RenderView call.

A hash from either parse method is shared by every method that takes a modelHash, including GetDiagnostics, GetSymbol, Evaluate, Instantiate, ExecuteAction and ExecuteState. The module retains the 16 most recently used models; an evicted hash is evicted for every method. GetServerInfo returns the build version and these capabilities, in order: type_facts, enum_values, evaluate_subject, symbol_attributes, unset_value, feature_values, inline_language, strict_conformance, parse_sources, complex_values, structured_values, measurement_refs, function_values, set_values, tensor_values, infinity_value, diagnostic_codes, schedule, final_time, metaobject_values, undetermined_value, performer, big_int_values.

The js build installs one synchronous host surface, globalThis.sysmlWasm, with version and call(method, paramsJSON). The call returns a JSON-RPC envelope string. Passing -stdio selects the sequential, Content-Length-framed JSON-RPC pipe instead; native and wasip1 builds always use that pipe. Every other method answers Unimplemented with <Method> is not served by sysml-wasm: it is served by sysml-grpc.

Measured on a go1.25.11 js/wasm build with -s -w -trimpath: 31,526,528 raw bytes, 7,790,873 bytes gzipped with gzip -9, and 5,479,244 bytes with Brotli -q 11.

What works

Everything that is the language implementation rather than the host around it:

  • -validate, -e/-eval, -query, -convert, -render and every document form but PDF, -engines, and the rest of the CLI;
  • the prompt, reading a line at a time from whatever the host wires up;
  • sysml-lsp -stdio, the whole language server protocol over standard input and output;
  • sysml-grpc -transport stdio, the service over the same framing, with its JSON-RPC bodies.

What does not

Each of these is refused with the reason, not left to fail on the platform's own words.

Not available What it answers
External processes — SMT solvers, external engines, PDF rendering, -compile a WebAssembly build cannot start external processes, named for what could not run: an SMT solver cannot run: … in sysml -engines, weasyprint cannot run: … from -doc-form pdf, codegen: cc cannot run: … from -compile, codegen: go cannot run: … from -compile -target go. Go's WebAssembly targets start no process at all, so installing the tool cannot help and the message says so instead of advising it
sysml-grpc -transport grpc and -transport connect … binds an address, and a WebAssembly build's network reaches only the process it runs in …; use -transport stdio. Go's net on these targets reaches only the same process, so a listener would report an address no client outside could dial and wait on it forever
HTTP clients, such as the client that reads and pushes a Flexo branch A transport error from the request. Outbound requests have no socket to make
Prompt history, tab completion, Ctrl-C, terminal width Nothing is faked: lines are read without editing, renderings are written unbounded, and an interrupt is the host's to deliver
"Is it a terminal?" — for at a terminal status and for - on standard input Never a terminal. Neither WASI preview 1 nor a browser offers the query, so a - reads the lines it is sent and ends at end of input, exactly as a redirected pipe does natively

The prompt is the one behavior that differs by degree: a native build writes it only where stdin is a terminal, and a WebAssembly build always writes it, because a host that cannot say whether its input is a terminal still needs one to be usable. A script that does not want it can drop it.

The gate

make wasm-check

compiles and vets the whole tree for both targets, links each command, and runs them under Node — the WASI host above for wasip1, wasm_exec for js. Node 24 or later is required: Node 22's WASI host crashes on the wasip1 modules. Without Node, the run half skips with the reason, and OPENSYSML_REQUIRE_WASM=1, which both CI systems set, turns that skip into a failure so a green run cannot be a skipped one.

The wasip1 cases are driven with files and end each server at end of input, for the reason under Running a WASI build; the js cases hold a pipe open across a request and its answer, and run a full service session and a full language server initialize/shutdown. Both halves say so where they differ.