@openmbee/opensysml 0.9.1
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/README.md +462 -0
- package/dist/browser/index.d.ts +12 -0
- package/dist/browser/index.js +34 -0
- package/dist/core/capabilities.d.ts +94 -0
- package/dist/core/capabilities.js +130 -0
- package/dist/core/connection.d.ts +79 -0
- package/dist/core/connection.js +110 -0
- package/dist/core/errors.d.ts +98 -0
- package/dist/core/errors.js +100 -0
- package/dist/core/index.d.ts +14 -0
- package/dist/core/index.js +10 -0
- package/dist/core/model.d.ts +139 -0
- package/dist/core/model.js +339 -0
- package/dist/core/package.d.ts +4 -0
- package/dist/core/package.js +4 -0
- package/dist/core/status.d.ts +15 -0
- package/dist/core/status.js +94 -0
- package/dist/core/transport.d.ts +10 -0
- package/dist/core/transport.js +69 -0
- package/dist/core/values.d.ts +271 -0
- package/dist/core/values.js +687 -0
- package/dist/generated/sysml_pb.d.ts +5668 -0
- package/dist/generated/sysml_pb.js +837 -0
- package/dist/node/binary.d.ts +127 -0
- package/dist/node/binary.js +564 -0
- package/dist/node/index.d.ts +31 -0
- package/dist/node/index.js +101 -0
- package/dist/node/service.d.ts +31 -0
- package/dist/node/service.js +238 -0
- package/dist/node/signing.d.ts +55 -0
- package/dist/node/signing.js +167 -0
- package/package.json +77 -0
- package/release-digests.json +60 -0
package/README.md
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
# @openmbee/opensysml
|
|
2
|
+
|
|
3
|
+
Node and browser client for OpenSysML: parse, inspect and instantiate SysML v2
|
|
4
|
+
models over the `sysml-grpc` service, using the [Connect
|
|
5
|
+
protocol](https://connectrpc.com/docs/protocol) with protobuf bodies. No native
|
|
6
|
+
addon, so an install is a plain registry fetch.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @openmbee/opensysml # from npm, once the first release is published
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { loads, connect } from "@openmbee/opensysml";
|
|
14
|
+
|
|
15
|
+
await using model = await loads(`package Demo {
|
|
16
|
+
part def Wheel { attribute radius : ScalarValues::Real = 0.3; }
|
|
17
|
+
part def Car { part wheels : Wheel[4]; attribute mass : ScalarValues::Real = 1500.0; }
|
|
18
|
+
}`);
|
|
19
|
+
|
|
20
|
+
const value = await model.eval("2 + 2"); // { kind: "int", value: 4n }
|
|
21
|
+
const car = await model.symbol("Demo::Car"); // by qualified name
|
|
22
|
+
await car.children(); // its members, as symbols
|
|
23
|
+
|
|
24
|
+
const tree = await model.instantiate("Demo::Car");
|
|
25
|
+
const wheels = tree.get("wheels"); // a FeatureValue union
|
|
26
|
+
if (wheels?.kind === "many") {
|
|
27
|
+
console.log(wheels.values.length); // 4
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`loads`/`load` open a connection of their own and close it with the model.
|
|
32
|
+
`connect()` is the longer-lived form, and parses more than one source over one
|
|
33
|
+
service and one parse cache:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
await using connection = await connect();
|
|
37
|
+
const first = await connection.load("model.sysml");
|
|
38
|
+
const second = await connection.loads("package Inline { part def Thing; }");
|
|
39
|
+
connection.info.capabilities; // what this service can do
|
|
40
|
+
connection.model(first.hash); // a model the service already holds
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Both `Connection` and `Model` implement `Symbol.asyncDispose`, so `await using`
|
|
44
|
+
closes them; `close()` is the explicit form, and is safe to call twice.
|
|
45
|
+
|
|
46
|
+
## Values are discriminated unions
|
|
47
|
+
|
|
48
|
+
Every `oneof` the service answers with arrives as a union to switch on, rather
|
|
49
|
+
than a generated message with optional fields:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
switch (value.kind) {
|
|
53
|
+
case "int": value.value; // bigint, never lossy
|
|
54
|
+
case "real": value.value; // number
|
|
55
|
+
case "complex": value.value.real; value.value.imaginary; // 1.5 - 2.0i, one value
|
|
56
|
+
case "boolean": value.value;
|
|
57
|
+
case "string": value.value;
|
|
58
|
+
case "quantity": value.magnitude; value.unit; // 1500.0 [kg]
|
|
59
|
+
case "measurementRef": value.unit; value.unitTerm; value.unitId; // a bare unit: km, reduced to 1000·metre
|
|
60
|
+
case "function": value.calcId; value.selfId; // a calc as a value: Demo::Sq, or holder.scale read off an object
|
|
61
|
+
case "array": value.dimensions; value.elements; // row-major, an element is any SysMLValue
|
|
62
|
+
case "vector": value.components; // { kind: "int" | "real" }[]
|
|
63
|
+
case "vectorQuantity": value.components; // QuantityValue[], a unit per component
|
|
64
|
+
case "set": value.elements; // SysMLValue[], each once, unordered
|
|
65
|
+
case "tensorQuantity": value.dimensions; value.components; // any rank, row-major QuantityValue[]
|
|
66
|
+
case "metaobject": value.elementId; value.metaclassId; // an element reflected on: x meta KerML::Feature
|
|
67
|
+
case "enum": value.value.name; value.value.value; // its literal/enumeration ids, and the scalar a `high = 3` literal carries
|
|
68
|
+
case "instance": value.id; // an object in the same tree
|
|
69
|
+
case "sequence": value.elements; // SysMLValue[]
|
|
70
|
+
case "undetermined": value.reason; value.countLower; value.countUpper; // the model leaves it open
|
|
71
|
+
case "infinity": break; // the unbounded `*`
|
|
72
|
+
case "null": value.reason; // evaluated, no value
|
|
73
|
+
case "unset": break; // declared, never given one
|
|
74
|
+
case "absent": break; // the service sent no value at all
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`unset`, `undetermined` and `absent` are distinct on purpose: the first is a feature the
|
|
79
|
+
model leaves without a value, the second a model-level answer the model leaves open (an
|
|
80
|
+
unbound feature, a count the multiplicity does not fix) that is read but never sent, and the
|
|
81
|
+
third a field the answer did not carry.
|
|
82
|
+
`SysMLVerdict` (`holds` / `fails` / `undecided`) and `FeatureValue` (`single` /
|
|
83
|
+
`many` / `error`) are unions of the same shape; every verdict arm carries a `standing` — the
|
|
84
|
+
engine that answered, the strength of its evidence (`observed`, `witnessed`, `bounded`,
|
|
85
|
+
`proved`) and the bounds it ran under — empty from a service without the `engines`
|
|
86
|
+
capability. Integers are `bigint`, because
|
|
87
|
+
the service's `int64` does not fit a `number` — an exact comparison against a
|
|
88
|
+
scenario expectation would otherwise be a lie.
|
|
89
|
+
|
|
90
|
+
## Two lifecycle modes
|
|
91
|
+
|
|
92
|
+
### A private child of this process (the default)
|
|
93
|
+
|
|
94
|
+
`connect()` with no address starts `sysml-grpc` as a child of this process with
|
|
95
|
+
`-port 0 -health-port 0 -report-address -exit-with-parent`, and reads the address
|
|
96
|
+
it bound from the child's first stdout line. No port is chosen, probed or
|
|
97
|
+
retried, so two processes starting at once cannot collide.
|
|
98
|
+
|
|
99
|
+
One child serves **every connection of a thread**: the first `connect()` starts
|
|
100
|
+
it, the last `close()` stops it, and sharing it shares the service's parse cache.
|
|
101
|
+
It is per thread rather than per process because the module state that holds it
|
|
102
|
+
is per thread — a `worker_threads` worker gets its own child, and closing the
|
|
103
|
+
worker's connections stops that child alone.
|
|
104
|
+
|
|
105
|
+
**No orphans, and the mechanism is not an exit hook.** The client holds the write
|
|
106
|
+
end of the child's stdin pipe and never writes to it; the child exits at end of
|
|
107
|
+
file. The kernel closes that pipe when the holder dies however it dies, which
|
|
108
|
+
survives what a `process.on("exit")` hook does not: `SIGKILL`, `process.abort()`,
|
|
109
|
+
an uncaught fatal error, a crash during shutdown.
|
|
110
|
+
`test/orphan.test.ts` proves it by `SIGKILL`ing a parent that holds a connection
|
|
111
|
+
and asserting the child is gone.
|
|
112
|
+
|
|
113
|
+
Node adds three wrinkles Python does not have, all deliberate here:
|
|
114
|
+
|
|
115
|
+
- **The event loop.** A referenced child handle keeps Node alive, so a script that
|
|
116
|
+
forgot `close()` would never exit. The child and its three stdio handles are
|
|
117
|
+
`unref()`ed as soon as the address arrives — before that they stay referenced,
|
|
118
|
+
or Node could exit in the middle of starting the service. `stop()` `ref()`s the
|
|
119
|
+
child again for as long as it waits for it to exit.
|
|
120
|
+
- **`detached: true`.** The child is started in a session of its own, so a
|
|
121
|
+
`SIGINT` meant for this process does not reach it mid-call; stdin is what ends
|
|
122
|
+
it. It is never `unref`ed *and* left running: this process holds the only write
|
|
123
|
+
end of its stdin.
|
|
124
|
+
- **Worker threads.** Each thread owns its own child, as above.
|
|
125
|
+
|
|
126
|
+
On **Windows** the guarantee is the same and rests on the same mechanism: the OS
|
|
127
|
+
closes the anonymous stdin pipe when the owning process exits, however it exits,
|
|
128
|
+
so the child sees end of file. What differs is that there is no process group to
|
|
129
|
+
signal and no `SIGKILL` — `child.kill()` is `TerminateProcess` — so the orphan
|
|
130
|
+
test is POSIX-only (`process.kill(pid, 0)` and `SIGKILL` have no equivalent), and
|
|
131
|
+
prompt shutdown on Windows comes from closing stdin rather than from a signal.
|
|
132
|
+
|
|
133
|
+
### A service someone else runs (explicit opt-in)
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
const connection = await connect({ address: "localhost:50051" });
|
|
137
|
+
await connection.close(); // that service keeps running
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
or set `$OPENSYSML_SERVICE=host:port`. A connection made this way never owns the
|
|
141
|
+
service: closing it disconnects and nothing else. There is no adoption of a
|
|
142
|
+
service left listening by another process, no pidfile and no port probing.
|
|
143
|
+
|
|
144
|
+
## The browser
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import { connect } from "@openmbee/opensysml/browser";
|
|
148
|
+
|
|
149
|
+
await using connection = await connect({ address: "https://sysml.example.com" });
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The browser entry point is the **explicit-address path only**: a browser cannot
|
|
153
|
+
spawn a process, so there is no private child there and nothing to fall back to.
|
|
154
|
+
It uses `@connectrpc/connect-web`, which is `fetch` and needs no proxy and no
|
|
155
|
+
sidecar.
|
|
156
|
+
|
|
157
|
+
Two limits to plan for rather than discover:
|
|
158
|
+
|
|
159
|
+
- The service must allow the page's **exact origin** — start it with
|
|
160
|
+
`-cors-allowed-origins https://app.example.com`, never `*` — and must be
|
|
161
|
+
served over TLS (`-tls-cert`/`-tls-key`) for an HTTPS page to reach it.
|
|
162
|
+
- `connect-go` v1.20 does not implement the base64 **`grpc-web-text`** variant.
|
|
163
|
+
This client does not need it: a `fetch`-based Connect client sends and reads
|
|
164
|
+
binary bodies directly. A `grpc-web` client that requires `-text` will not work
|
|
165
|
+
against this service, whatever the client.
|
|
166
|
+
|
|
167
|
+
`test/browser.test.ts` runs this entry point against a real service over the same
|
|
168
|
+
`fetch` transport, and asserts the allowed origin is answered on the preflight
|
|
169
|
+
while another origin is not.
|
|
170
|
+
|
|
171
|
+
## Protobuf, not JSON
|
|
172
|
+
|
|
173
|
+
Bodies are protobuf by default. JSON is available (`connect({ encoding: "json" })`)
|
|
174
|
+
for `curl`-shaped debugging, and it is the same answers, but
|
|
175
|
+
[`docs/internals/design/transport-evaluation.md`](../../docs/internals/design/transport-evaluation.md)
|
|
176
|
+
measured a 468 KB response at ~6.5 ms with a protobuf body against ~42 ms with
|
|
177
|
+
JSON — `protojson` CPU on the service, not the ~10% difference in bytes. The
|
|
178
|
+
gRPC protocol is also available (`connect({ protocol: "grpc" })`) and carries
|
|
179
|
+
protobuf only; asking for `{ protocol: "grpc", encoding: "json" }` is refused
|
|
180
|
+
rather than silently downgraded.
|
|
181
|
+
|
|
182
|
+
## Capability negotiation
|
|
183
|
+
|
|
184
|
+
Clients negotiate on the capability names `GetServerInfo` reports, not on
|
|
185
|
+
versions:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
import { CAPABILITY_EVALUATE_SUBJECT } from "@openmbee/opensysml";
|
|
189
|
+
|
|
190
|
+
if (connection.info.has(CAPABILITY_EVALUATE_SUBJECT)) {
|
|
191
|
+
await model.eval("mass", { subject: "Demo::sedan" });
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The client checks the advertised list **before** making such a call so it can
|
|
196
|
+
raise a `MissingCapabilityError` naming the service, its version and the way to
|
|
197
|
+
get one that has it. A direct capability-gated request to a service without the
|
|
198
|
+
capability is refused with `UNIMPLEMENTED`; response-population capabilities
|
|
199
|
+
instead omit the fields they name. A service without `structured_values`,
|
|
200
|
+
`measurement_refs`, `function_values`, `set_values`, `tensor_values` or
|
|
201
|
+
`metaobject_values` sends the value kinds those name (`array`, `vector`,
|
|
202
|
+
`vectorQuantity`; `measurementRef`; `function`; `set`; `tensorQuantity`;
|
|
203
|
+
`metaobject`) as `null` with an `unsupported: …` reason.
|
|
204
|
+
A function closing over the bindings of a behavior body has no wire form and is
|
|
205
|
+
sent as `null` by every service. A `set` arrives
|
|
206
|
+
with its elements in the service's canonical order, so two equal sets arrive
|
|
207
|
+
alike, and one listing a member twice is a `MalformedValueError`, whether it
|
|
208
|
+
arrives or is about to be sent; one sent to the service may list its members in
|
|
209
|
+
any order. `valuesEqual` is the membership test, as the service judges it: sets
|
|
210
|
+
by membership, sequences in order, numbers by value — `1` and `1.0` are one
|
|
211
|
+
member, exactly across the whole `int` range — and a quantity by magnitude
|
|
212
|
+
through its `unitTerm`, so `1 [m]` is `100 [cm]` (exactly, while the magnitude
|
|
213
|
+
is an `int` and the scale a whole ratio); one without a `unitTerm` is compared
|
|
214
|
+
in its unit as written. A `tensorQuantity` carries its `dimensions` and one
|
|
215
|
+
quantity per component, row-major. A `metaobject` — what `x meta KerML::Feature`
|
|
216
|
+
or the last element of `x.metadata` evaluates to — names the element reflected
|
|
217
|
+
on (`elementId`, its identity) and the element's own metaclass (`metaclassId`,
|
|
218
|
+
not the type it was cast to); two are `valuesEqual` exactly when they name one
|
|
219
|
+
element. Its features (`declaredName`, `ownedFeature`, …) are read in the model,
|
|
220
|
+
not carried. One sent to the service may leave `metaclassId` empty to have the
|
|
221
|
+
model's used; one naming a metaclass that is not the element's is refused, and
|
|
222
|
+
one naming no element is a `MalformedValueError`.
|
|
223
|
+
|
|
224
|
+
## Failures are typed
|
|
225
|
+
|
|
226
|
+
Every failure is an `OpenSysMLError`. A call the service refused is a
|
|
227
|
+
`ServiceError` whose `code` is the RPC status it came back with (`"NOT_FOUND"`,
|
|
228
|
+
`"INVALID_ARGUMENT"`, …), and the statuses worth catching by themselves have a
|
|
229
|
+
subclass: `ModelNotFoundError` (the service no longer holds that hash),
|
|
230
|
+
`ModelFileNotFoundError`, `InvalidRequestError`, `ServiceTimeoutError`,
|
|
231
|
+
`UnsupportedOperationError`. A name the model has not got is a
|
|
232
|
+
`SymbolNotFoundError`, which carries the `symbolName` it looked for and the
|
|
233
|
+
`suggestions` closest to it:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
try {
|
|
237
|
+
await model.symbol("Wheeel");
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (error instanceof SymbolNotFoundError) {
|
|
240
|
+
console.error(`no ${error.symbolName}; did you mean ${error.suggestions[0]}?`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Source that does not parse is not a failure: `load`/`loads` return a model whose
|
|
246
|
+
`hasErrors` is true and whose `diagnostics` say where. Each `ModelDiagnostic` has
|
|
247
|
+
`severity`, `message`, `code` and an optional location; branch on `code`
|
|
248
|
+
(`"syntax"`, a validation code such as `"unresolved"`, `"choice-point"`,
|
|
249
|
+
`"guard-unevaluable"`; `""` when the service assigned none), not on the message
|
|
250
|
+
text. A service that populates `code` advertises `CAPABILITY_DIAGNOSTIC_CODES`;
|
|
251
|
+
without it every code is `""`. Options that cannot work
|
|
252
|
+
(an encoding that is not one, a timeout that cannot elapse, `grpc` with `json`)
|
|
253
|
+
are refused before a connection is opened or a service started.
|
|
254
|
+
|
|
255
|
+
## The service binary
|
|
256
|
+
|
|
257
|
+
The binary comes from an **optional per-platform npm package**, selected by npm
|
|
258
|
+
from its `os`/`cpu` metadata:
|
|
259
|
+
|
|
260
|
+
| package | platform |
|
|
261
|
+
| --- | --- |
|
|
262
|
+
| `@openmbee/opensysml-sysml-grpc-linux-x64` | Linux x86-64 |
|
|
263
|
+
| `@openmbee/opensysml-sysml-grpc-linux-arm64` | Linux arm64 |
|
|
264
|
+
| `@openmbee/opensysml-sysml-grpc-darwin-x64` | macOS Intel |
|
|
265
|
+
| `@openmbee/opensysml-sysml-grpc-darwin-arm64` | macOS Apple silicon |
|
|
266
|
+
| `@openmbee/opensysml-sysml-grpc-win32-x64` | Windows x86-64 |
|
|
267
|
+
|
|
268
|
+
That is a normal registry install: npm verifies the tarball against the
|
|
269
|
+
registry's integrity hash, and **there is no postinstall script**, so a platform
|
|
270
|
+
with a package never downloads anything.
|
|
271
|
+
|
|
272
|
+
Resolution order:
|
|
273
|
+
|
|
274
|
+
1. `$OPENSYSML_BINARY` — a path to a binary, which wins over everything;
|
|
275
|
+
2. the platform package above;
|
|
276
|
+
3. `~/.opensysml/bin/sysml-grpc`, the cache the Python client also uses, filled
|
|
277
|
+
by a verified download of a release when nothing above resolved;
|
|
278
|
+
4. `sysml-grpc` on `$PATH`;
|
|
279
|
+
5. otherwise: an error, or connect to a service someone else runs.
|
|
280
|
+
|
|
281
|
+
### Downloading a release
|
|
282
|
+
|
|
283
|
+
`resolveBinary()` downloads a `sysml-grpc-<os>-<arch>` release asset into
|
|
284
|
+
`~/.opensysml/bin/sysml-grpc` (`.exe` on Windows) when the steps above resolved
|
|
285
|
+
nothing — the same cache, the same metadata beside it in `sysml-grpc.json`, and
|
|
286
|
+
the same trust model as the Python client, so either client can use what the
|
|
287
|
+
other downloaded. `process.platform`/`process.arch` map to the five published
|
|
288
|
+
pairs (`linux-amd64`, `linux-arm64`, `darwin-amd64`, `darwin-arm64`,
|
|
289
|
+
`windows-amd64`); any other pair is an error naming it rather than a fetch.
|
|
290
|
+
|
|
291
|
+
| variable | effect |
|
|
292
|
+
| --- | --- |
|
|
293
|
+
| `$OPENSYSML_BINARY` | a path to a binary, which wins over everything |
|
|
294
|
+
| `$OPENSYSML_GRPC_VERSION` | the release to download, else `latest` from the releases API |
|
|
295
|
+
| `$OPENSYSML_GITHUB_REPO` | the release repository, default `Open-MBEE/OpenSysML` |
|
|
296
|
+
| `$OPENSYSML_ALLOW_UNPINNED_DOWNLOAD` | `<owner/repo>`, or `1` for any repository: accept same-origin trust |
|
|
297
|
+
|
|
298
|
+
The download goes to a temporary file, is hashed, and only then replaces the
|
|
299
|
+
cache path atomically and is `chmod 0700`ed (POSIX); a download that does not
|
|
300
|
+
verify leaves an existing cached binary and its metadata untouched and removes
|
|
301
|
+
the temporary file. A cached binary of another version is replaced with a
|
|
302
|
+
warning rather than used, and every request times out after 15 seconds. A
|
|
303
|
+
transport failure falls back to a cached binary that still verifies; a digest or
|
|
304
|
+
signature failure never does.
|
|
305
|
+
|
|
306
|
+
### What a download is verified against
|
|
307
|
+
|
|
308
|
+
In order, and each step refuses rather than falling back to the next:
|
|
309
|
+
|
|
310
|
+
1. **A shipped pin.** `release-digests.json`, synced from
|
|
311
|
+
`client/release-digests.json` by `python3 scripts/sync-release-digests.py`
|
|
312
|
+
and published in the tarball, pins the SHA-256 of every asset of a release.
|
|
313
|
+
Where it pins one, that is what the bytes must hash to, and a served
|
|
314
|
+
`.sha256` that disagrees is tampering: the download fails.
|
|
315
|
+
2. **The release's signed manifest.** With no pin, the client downloads
|
|
316
|
+
`SHA256SUMS.txt` and its sigstore bundle `SHA256SUMS.txt.bundle`, verifies
|
|
317
|
+
the bundle against the release pipeline's certificate identity (the CircleCI
|
|
318
|
+
OIDC issuer and project in `src/node/signing.ts`), and takes the digest from
|
|
319
|
+
the verified manifest. Anything short of that — no bundle, a signature that
|
|
320
|
+
does not verify, another signer, an expired certificate, a manifest changed
|
|
321
|
+
after signing, a repository with no known signer, or the optional sigstore
|
|
322
|
+
packages not installed — is refused exactly as an unpinned release is. A
|
|
323
|
+
manifest digest that contradicts a pin is an error, not a downgrade.
|
|
324
|
+
3. **Nothing.** The download fails naming the version, because the `.sha256`
|
|
325
|
+
served beside a binary comes from whoever served the binary: it detects
|
|
326
|
+
corruption but not a compromised release.
|
|
327
|
+
`$OPENSYSML_ALLOW_UNPINNED_DOWNLOAD=<owner/repo>` (or `=1` for any
|
|
328
|
+
repository) accepts that same-origin trust explicitly, with a warning saying
|
|
329
|
+
so. It is never a way around a failed signature or a pin mismatch.
|
|
330
|
+
|
|
331
|
+
Verification uses `@sigstore/verify` with `@sigstore/bundle`,
|
|
332
|
+
`@sigstore/protobuf-specs` and `@sigstore/tuf` — the packages the `sigstore`
|
|
333
|
+
package is itself built from — as **optional** dependencies, so the client
|
|
334
|
+
installs and works without them and a release with no pin is refused where they
|
|
335
|
+
are missing. They are used rather than `sigstore.verify` because that entry
|
|
336
|
+
point takes its trusted root only through TUF, and both the Python client and
|
|
337
|
+
these tests verify against a recorded trusted root offline.
|
|
338
|
+
|
|
339
|
+
The per-platform packages are built by
|
|
340
|
+
`npm run platform-packages -- --binaries <dir>`, and each binary is packaged
|
|
341
|
+
**only** if its bytes match the `.sha256` sidecar beside it; a missing sidecar is
|
|
342
|
+
refused rather than trusted. The release job cross-compiles those binaries from
|
|
343
|
+
the tagged revision in the same run that publishes the packages and writes the
|
|
344
|
+
sidecars there, so nothing is downloaded to authenticate — which is the job the
|
|
345
|
+
Python client's pinned digests do — and an install is a normal registry fetch
|
|
346
|
+
carrying npm's own integrity hashes. npm's `--provenance` is not used because
|
|
347
|
+
the CLI mints attestations only on GitHub Actions and GitLab CI/CD, and this
|
|
348
|
+
repository releases from CircleCI. The README of each package records the digest
|
|
349
|
+
of the binary it carries.
|
|
350
|
+
|
|
351
|
+
## Generated stubs
|
|
352
|
+
|
|
353
|
+
`src/generated/sysml_pb.ts` is generated by `buf` from `api/proto/sysml.proto`
|
|
354
|
+
through the `protoc-gen-es` entry in `api/proto/buf.gen.ts.yaml`. The plugin is this
|
|
355
|
+
package's devDependency, pinned in `package-lock.json` at the same version as the
|
|
356
|
+
`@bufbuild/protobuf` runtime the stubs import, so `make proto` regenerates them
|
|
357
|
+
from `node_modules` with no hand steps and no network fetch at generation time.
|
|
358
|
+
|
|
359
|
+
**The stubs are committed**, for the same reason the Python ones are: `npm
|
|
360
|
+
install @openmbee/opensysml` must not need `buf`, Go or a network fetch of a
|
|
361
|
+
plugin, and a published tarball has to contain the compiled output. CI runs
|
|
362
|
+
`make proto-ts` and fails on any diff, so committed and generated cannot drift.
|
|
363
|
+
|
|
364
|
+
## What v1 does not do
|
|
365
|
+
|
|
366
|
+
This client covers `GetServerInfo`, `ParseFile`, `GetSymbol`, `Evaluate` and
|
|
367
|
+
`Instantiate` — connection, lifecycle, capability negotiation, and the five RPCs
|
|
368
|
+
above. Deliberately **not** in v1, rather than half-implemented:
|
|
369
|
+
|
|
370
|
+
- generated model-ergonomics types (`python -m opensysml.generate`'s equivalent);
|
|
371
|
+
- the edit API (`ApplyEdits`);
|
|
372
|
+
- RDF conversion (`Convert`);
|
|
373
|
+
- verification helpers (`VerifyConstraint`, `VerifyRequirement`,
|
|
374
|
+
`VerifySatisfaction`);
|
|
375
|
+
- `Query`, `GetDiagnostics`, `EvaluateCalc`, `RunAnalysis`, `ExecuteAction`, `ExecuteState`.
|
|
376
|
+
|
|
377
|
+
`connection.rpc` is the escape hatch: it is the generated Connect client, so any
|
|
378
|
+
RPC not covered here can still be called, without the ergonomic layer.
|
|
379
|
+
|
|
380
|
+
## Examples
|
|
381
|
+
|
|
382
|
+
`examples/` is six programs against one model, a rover, in `examples/model.ts`.
|
|
383
|
+
They are written to be read in order, they assert what they print, and the test
|
|
384
|
+
suite runs every one of them, so they cannot drift from the API.
|
|
385
|
+
|
|
386
|
+
```bash
|
|
387
|
+
npm run examples # all of them, in order
|
|
388
|
+
npm run example 03 # one, by number or name
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
| example | what it shows |
|
|
392
|
+
| --- | --- |
|
|
393
|
+
| `01-tour` | connect, parse, look up, evaluate, instantiate |
|
|
394
|
+
| `02-values` | every value kind, and what it decodes to in JavaScript |
|
|
395
|
+
| `03-symbols` | walking, lookup by name and id, type facts, adoption by hash |
|
|
396
|
+
| `04-instances` | instance trees, single and repeated features, unset and absent |
|
|
397
|
+
| `05-diagnostics` | syntax errors, the error each failure raises, refused options |
|
|
398
|
+
| `06-connections` | ownership, an external service, both protocols, calls at once |
|
|
399
|
+
|
|
400
|
+
## Conformance
|
|
401
|
+
|
|
402
|
+
The suite in `conformance/` is the service contract, and this client runs it
|
|
403
|
+
**through its public API** — `load`/`loads`, `eval`, `symbol`, `instantiate` —
|
|
404
|
+
not through the generated stubs. A scenario whose RPC v1 does not cover is
|
|
405
|
+
skipped with a reason, and the report has the same shape `tools/cmd/conformance` emits:
|
|
406
|
+
|
|
407
|
+
```bash
|
|
408
|
+
npm run conformance -- --allow-skips --report report.json
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
| protocol | ran | passed | failed | skipped |
|
|
412
|
+
| --- | --: | --: | --: | --: |
|
|
413
|
+
| `grpc` | 59 | 23 | 0 | 36 |
|
|
414
|
+
| `connect` | 59 | 23 | 0 | 36 |
|
|
415
|
+
| `connect-json` | 59 | 23 | 0 | 36 |
|
|
416
|
+
| **total** | **177** | **69** | **0** | **108** |
|
|
417
|
+
|
|
418
|
+
The 36 skips per protocol are 35 scenarios for the 10 RPCs listed above plus one
|
|
419
|
+
the public API cannot express: a `ParseFile` naming no source, since `load` and
|
|
420
|
+
`loads` always name one. Every skip carries its reason in the report.
|
|
421
|
+
|
|
422
|
+
**The runner is not vacuous.** `--mutate <name>` corrupts a response on its way
|
|
423
|
+
through the client, and each mutation makes at least one scenario fail:
|
|
424
|
+
|
|
425
|
+
| `--mutate` | what it breaks | caught by |
|
|
426
|
+
| --- | --- | --- |
|
|
427
|
+
| `hide-capability` | drops a capability from `GetServerInfo` | `server_info/...` |
|
|
428
|
+
| `drop-diagnostics` | drops parse diagnostics | `parse/a_syntax_error_...` |
|
|
429
|
+
| `blank-symbol-kind` | blanks `SymbolInfo.kind` | `symbol/...` |
|
|
430
|
+
| `shift-integer` | adds 1 to an integer result | `evaluate/arithmetic_is_an_integer` |
|
|
431
|
+
| `drop-feature-values` | empties an instance's feature values | `instantiate/...` |
|
|
432
|
+
|
|
433
|
+
```bash
|
|
434
|
+
npm run conformance -- --allow-skips --mutate shift-integer # must fail
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
## Development
|
|
438
|
+
|
|
439
|
+
```bash
|
|
440
|
+
npm install
|
|
441
|
+
npm run build # dist/, what is published
|
|
442
|
+
npm run typecheck
|
|
443
|
+
npm run lint
|
|
444
|
+
npm test # unit, lifecycle and service-backed tests
|
|
445
|
+
npm run conformance -- --allow-skips
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
The service-backed tests build `sysml-grpc` from this checkout on first use, or
|
|
449
|
+
use `$OPENSYSML_BINARY` when it names one. The `node-test` job runs all five
|
|
450
|
+
commands, plus the mutation checks and a stub-drift check, in both
|
|
451
|
+
`.github/workflows/pr.yml` (pull requests) and `.circleci/config.yml`.
|
|
452
|
+
|
|
453
|
+
## Release
|
|
454
|
+
|
|
455
|
+
Nothing here is published yet; the procedure is in
|
|
456
|
+
[docs/project/releasing.md](../../docs/project/releasing.md) under "Releasing
|
|
457
|
+
@openmbee/opensysml to npm". In short: the core `v*` tag's `release` workflow
|
|
458
|
+
publishes this package and its five per-platform packages at the version
|
|
459
|
+
`client/python/opensysml/_version.py` declares, carrying the release's own
|
|
460
|
+
`sysml-grpc` binaries. The granular npm token it needs already lives in the
|
|
461
|
+
`npm` context; granular tokens expire after at most 90 days, so rotation before
|
|
462
|
+
a release is a standing task.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Connection } from "../core/connection.js";
|
|
2
|
+
import type { TransportOptions } from "../core/connection.js";
|
|
3
|
+
export * from "../core/index.js";
|
|
4
|
+
/** How a browser connects: the address is required, because nothing can be started. */
|
|
5
|
+
export interface BrowserConnectOptions extends TransportOptions {
|
|
6
|
+
address: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Connects to a running service. The service must allow this page's exact origin
|
|
10
|
+
* (`-cors-allowed-origins`) and, from an HTTPS page, must be served over TLS.
|
|
11
|
+
*/
|
|
12
|
+
export declare function connect(options: BrowserConnectOptions): Promise<Connection>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Browser entry point: fetch-based Connect, explicit address only. A browser
|
|
2
|
+
// cannot spawn a service, so there is no private-child path here.
|
|
3
|
+
import { createConnectTransport } from "@connectrpc/connect-web";
|
|
4
|
+
import { Connection } from "../core/connection.js";
|
|
5
|
+
import { OpenSysMLError } from "../core/errors.js";
|
|
6
|
+
import { baseUrl, encodingOf, interceptors, timeoutOf } from "../core/transport.js";
|
|
7
|
+
export * from "../core/index.js";
|
|
8
|
+
/**
|
|
9
|
+
* Connects to a running service. The service must allow this page's exact origin
|
|
10
|
+
* (`-cors-allowed-origins`) and, from an HTTPS page, must be served over TLS.
|
|
11
|
+
*/
|
|
12
|
+
export async function connect(options) {
|
|
13
|
+
if (options.address.trim() === "") {
|
|
14
|
+
throw new OpenSysMLError("a browser cannot start a sysml-grpc service, so connect() needs the address of one");
|
|
15
|
+
}
|
|
16
|
+
const url = baseUrl(options.address);
|
|
17
|
+
const encoding = encodingOf(options);
|
|
18
|
+
const timeoutMs = timeoutOf(options);
|
|
19
|
+
const transport = createConnectTransport({
|
|
20
|
+
baseUrl: url,
|
|
21
|
+
useBinaryFormat: encoding === "protobuf",
|
|
22
|
+
interceptors: interceptors(options),
|
|
23
|
+
});
|
|
24
|
+
return Connection.open({
|
|
25
|
+
transport,
|
|
26
|
+
encoding,
|
|
27
|
+
backend: {
|
|
28
|
+
origin: url,
|
|
29
|
+
// Nothing to release: the page never owned the service.
|
|
30
|
+
release: () => Promise.resolve(),
|
|
31
|
+
},
|
|
32
|
+
timeoutMs,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { OpenSysMLError } from "./errors.js";
|
|
2
|
+
/** Static type facts on a symbol: `typeInfo`, `multiplicity`, `specializations`. */
|
|
3
|
+
export declare const CAPABILITY_TYPE_FACTS = "type_facts";
|
|
4
|
+
/** Populated `SymbolInfo.attributes`. */
|
|
5
|
+
export declare const CAPABILITY_SYMBOL_ATTRIBUTES = "symbol_attributes";
|
|
6
|
+
/** Evaluating an expression against an instantiated subject. */
|
|
7
|
+
export declare const CAPABILITY_EVALUATE_SUBJECT = "evaluate_subject";
|
|
8
|
+
/** An object's values as `Instance.feature_values`. */
|
|
9
|
+
export declare const CAPABILITY_FEATURE_VALUES = "feature_values";
|
|
10
|
+
/** An enumeration literal as `Value.enum_literal`. */
|
|
11
|
+
export declare const CAPABILITY_ENUM_VALUES = "enum_values";
|
|
12
|
+
/** A valueless feature of a value type as `Value.unset`. */
|
|
13
|
+
export declare const CAPABILITY_UNSET_VALUE = "unset_value";
|
|
14
|
+
/** A complex number as `Value.complex`, rather than an unsupported null. */
|
|
15
|
+
export declare const CAPABILITY_COMPLEX_VALUES = "complex_values";
|
|
16
|
+
/** An array, a vector and a vector quantity as their own arms, rather than unsupported nulls. */
|
|
17
|
+
export declare const CAPABILITY_STRUCTURED_VALUES = "structured_values";
|
|
18
|
+
/** A bare measurement unit (`SI::m`, `m / s`) as `Value.measurement_ref`, rather than an unsupported null. */
|
|
19
|
+
export declare const CAPABILITY_MEASUREMENT_REFS = "measurement_refs";
|
|
20
|
+
/** A calc held as a value as `Value.function`, named by its declaration, rather than an unsupported null. */
|
|
21
|
+
export declare const CAPABILITY_FUNCTION_VALUES = "function_values";
|
|
22
|
+
/** The unbounded value `*` as `Value.infinity`, rather than an unsupported null. */
|
|
23
|
+
export declare const CAPABILITY_INFINITY_VALUE = "infinity_value";
|
|
24
|
+
/** `Diagnostic.code` is populated, so an empty code is a finding none was assigned. */
|
|
25
|
+
export declare const CAPABILITY_DIAGNOSTIC_CODES = "diagnostic_codes";
|
|
26
|
+
/** A unique, unordered collection (a `Collections::Set`'s elements) as `Value.set`, rather than an unsupported null. */
|
|
27
|
+
export declare const CAPABILITY_SET_VALUES = "set_values";
|
|
28
|
+
/** A tensor quantity of any rank as `Value.tensor_quantity`, rather than an unsupported null. */
|
|
29
|
+
export declare const CAPABILITY_TENSOR_VALUES = "tensor_values";
|
|
30
|
+
/** An element reflected on (`x meta T`, the last of `x.metadata`) as `Value.metaobject`, rather than an unsupported null. */
|
|
31
|
+
export declare const CAPABILITY_METAOBJECT_VALUES = "metaobject_values";
|
|
32
|
+
/** `finalTime` on an execution response: the run's simulation clock when it ended, in seconds. */
|
|
33
|
+
export declare const CAPABILITY_FINAL_TIME = "final_time";
|
|
34
|
+
/** `ParseFileRequest.language`, which declares the language of inline content. */
|
|
35
|
+
export declare const CAPABILITY_INLINE_LANGUAGE = "inline_language";
|
|
36
|
+
/** `ParseFileRequest.strict_conformance`. */
|
|
37
|
+
export declare const CAPABILITY_STRICT_CONFORMANCE = "strict_conformance";
|
|
38
|
+
/** The `Convert` RPC. Not used by this version; see the README. */
|
|
39
|
+
export declare const CAPABILITY_CONVERT = "convert";
|
|
40
|
+
/** The verification RPCs. Not used by this version; see the README. */
|
|
41
|
+
export declare const CAPABILITY_VERIFICATION = "verification";
|
|
42
|
+
/** What the body of a verification case answered, as `verification_verdicts`. */
|
|
43
|
+
export declare const CAPABILITY_VERIFICATION_VERDICTS = "verification_verdicts";
|
|
44
|
+
/** `RunAnalysisResponse.evaluations`: each call the run made to a calc held as a value, such as a trade study's evaluation of every alternative. */
|
|
45
|
+
export declare const CAPABILITY_CASE_EVALUATIONS = "case_evaluations";
|
|
46
|
+
/** The `Query` RPC. Not used by this version; see the README. */
|
|
47
|
+
export declare const CAPABILITY_QUERY = "query";
|
|
48
|
+
/** The `ApplyEdits` RPC. Not used by this version; see the README. */
|
|
49
|
+
export declare const CAPABILITY_APPLY_EDITS = "apply_edits";
|
|
50
|
+
/** `ApplyEdits` can add `first`/`then` action sequencing members. */
|
|
51
|
+
export declare const CAPABILITY_SEQUENCE_AUTHORING = "sequence_authoring";
|
|
52
|
+
/** `ApplyEdits` can add action-body statements and succession source multiplicities. */
|
|
53
|
+
export declare const CAPABILITY_ACTION_BODY_STATEMENT_AUTHORING = "action_body_statement_authoring";
|
|
54
|
+
/** `ApplyEdits` edits a model of several documents as one batch and answers each edited document by name in `documents`. Not used by this version; see the README. */
|
|
55
|
+
export declare const CAPABILITY_EDIT_DOCUMENTS = "edit_documents";
|
|
56
|
+
/** The `schedule` field of the execution requests, naming the scheduling policy. Not used by this version; see the README. */
|
|
57
|
+
export declare const CAPABILITY_SCHEDULE = "schedule";
|
|
58
|
+
/** The `explore` scheduling policy, answering with every `outcomes` entry and an `exploration` status. Not used by this version; see the README. */
|
|
59
|
+
export declare const CAPABILITY_SCHEDULE_EXPLORE = "schedule_explore";
|
|
60
|
+
/** `performerSymbolId` on the action and state requests: the object the behavior runs on, a declaration or a path from one into its parts. Not used by this version; see the README. */
|
|
61
|
+
export declare const CAPABILITY_PERFORMER = "performer";
|
|
62
|
+
/** The `ListEngines` RPC, the `engine` field selecting an analysis engine, and `engine`, `strength` and `bounds` on the answers. Not used by this version; see the README. */
|
|
63
|
+
export declare const CAPABILITY_ENGINES = "engines";
|
|
64
|
+
/** A model-level result the model leaves open as `Value.undetermined`, read as an `undetermined` value. */
|
|
65
|
+
export declare const CAPABILITY_UNDETERMINED_VALUE = "undetermined_value";
|
|
66
|
+
/** Self-description of the service a connection talks to. */
|
|
67
|
+
export declare class ServerInfo {
|
|
68
|
+
/** Version the service reports. Informational only; empty when unanswered. */
|
|
69
|
+
readonly version: string;
|
|
70
|
+
readonly capabilities: ReadonlySet<string>;
|
|
71
|
+
/** Whether the service answered the handshake; false means it predates `GetServerInfo`. */
|
|
72
|
+
readonly answered: boolean;
|
|
73
|
+
/** Where the service came from: the binary this client started, or the address it dialled. */
|
|
74
|
+
readonly origin: string;
|
|
75
|
+
constructor(init: {
|
|
76
|
+
version: string;
|
|
77
|
+
capabilities: Iterable<string>;
|
|
78
|
+
answered: boolean;
|
|
79
|
+
origin: string;
|
|
80
|
+
});
|
|
81
|
+
has(capability: string): boolean;
|
|
82
|
+
/** One-line description of the service, for an error message. */
|
|
83
|
+
describe(): string;
|
|
84
|
+
}
|
|
85
|
+
/** The connected service does not report a capability the operation requires. */
|
|
86
|
+
export declare class MissingCapabilityError extends OpenSysMLError {
|
|
87
|
+
readonly capability: string;
|
|
88
|
+
readonly info: ServerInfo;
|
|
89
|
+
constructor(capability: string, info: ServerInfo, remedy: string);
|
|
90
|
+
}
|
|
91
|
+
/** Throws unless the service reports `capability`. */
|
|
92
|
+
export declare function requireCapability(info: ServerInfo, capability: string, remedy: string): void;
|
|
93
|
+
/** Remedy text for a service that lacks `capability`, naming both routes to one that has it. */
|
|
94
|
+
export declare function upgradeRemedy(capability: string): string;
|