Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -726,7 +726,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 10
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
Expand All @@ -748,6 +748,13 @@ jobs:
BUILD_TIME="$(date -u '+%Y-%m-%d_%H:%M:%S')" \
GO_VERSION="$(go version | awk '{print $3}')"

- name: Build the combined WebAssembly module
run: |
mkdir -p bin/wasm
GOOS=js GOARCH=wasm go build -trimpath -ldflags="-s -w" \
-o bin/wasm/sysml-wasm.wasm ./cmd/sysml-wasm
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" bin/wasm/

- name: Verify binaries
run: |
./bin/sysml --version
Expand Down Expand Up @@ -1109,6 +1116,9 @@ jobs:
run: |
chmod +x bin/sysml-grpc
echo "OPENSYSML_BINARY=$(pwd)/bin/sysml-grpc" >> "$GITHUB_ENV"
echo "OPENSYSML_WASM=$(pwd)/bin/wasm/sysml-wasm.wasm" >> "$GITHUB_ENV"
echo "OPENSYSML_WASM_EXEC=$(pwd)/bin/wasm/wasm_exec.js" >> "$GITHUB_ENV"
echo "OPENSYSML_REQUIRE_WASM=1" >> "$GITHUB_ENV"

- name: Install the client's dependencies
working-directory: client/node
Expand Down
1 change: 1 addition & 0 deletions changes/unreleased/node-wasm-transport.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **The Node and browser clients can connect to the combined `sysml-wasm` module without a service.** Node defaults to a worker thread, with inline execution available, and browsers can run inline or in a supplied worker.
55 changes: 55 additions & 0 deletions client/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,61 @@ Two limits to plan for rather than discover:
`fetch` transport, and asserts the allowed origin is answered on the preflight
while another origin is not.

## WebAssembly, without a service

`connectWasm()` uses the same `Connection`, `Model`, values and errors over the
combined `sysml-wasm` module. It serves `ParseFile`, `ParseSources`,
`GetDiagnostics`, `GetSymbol`, `Evaluate`, `Instantiate`, `ExecuteAction`,
`ExecuteState` and `GetServerInfo`. Those are the module's complete RPC surface;
other capability-gated operations fail with `MissingCapabilityError`, and a
direct unsupported RPC fails with `UNIMPLEMENTED`.
The adapter uses JSON encoding; requesting protobuf encoding is refused.

In Node, `connectWasm()` runs a worker thread by default:

```ts
import { connectWasm } from "@openmbee/opensysml";

await using connection = await connectWasm({
wasm: "./sysml-wasm.wasm",
wasmExec: "/path/to/the/matching/wasm_exec.js",
});
const model = await connection.loads("package Demo { part def Car; }");
```

The WASM module and `wasm_exec.js` must come from compatible Go toolchains.
The worker remains referenced while the connection is open, so `close()` or
`await using` ends it. `thread: "inline"` runs Go on the calling thread instead;
it is useful when a worker is unavailable, but blocks that thread during a call.
Closing an inline connection disables its client surface; Go has no exit hook to
stop the running module.
A worker-mode deadline rejects the waiting call without interrupting Go, so
later worker calls queue behind work that outlived its deadline. Inline calls
run synchronously and cannot be interrupted while they block the JavaScript
thread.

In a browser, omit `worker` to run inline, or provide a module worker serving the
package's `browser/wasm-worker` entry point:

```ts
import { connectWasm } from "@openmbee/opensysml/browser";

const worker = new Worker("/assets/opensysml-wasm-worker.js", { type: "module" });
await using connection = await connectWasm({
wasm: new URL("./sysml-wasm.wasm", import.meta.url),
wasmExec: new URL("./wasm_exec.js", import.meta.url),
worker,
});
```

If the page loads `wasm_exec.js` itself, omit `wasmExec` to use the installed Go
constructor.

The browser worker module can be bundled from
`@openmbee/opensysml/browser/wasm-worker`. The combined module measures about
7.8 MB gzipped and 5.5 MB with Brotli. A package containing the matching WASM
and Go runtime artifacts will be published separately in a future release.

## Protobuf, not JSON

Bodies are protobuf by default. JSON is available (`connect({ encoding: "json" })`)
Expand Down
3 changes: 2 additions & 1 deletion client/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"./browser": {
"types": "./dist/browser/index.d.ts",
"default": "./dist/browser/index.js"
}
},
"./browser/wasm-worker": "./dist/browser/wasm-worker.js"
},
"bin": {
"opensysml-generate": "dist/node/generate-cli.js"
Expand Down
2 changes: 2 additions & 0 deletions client/node/src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { OpenSysMLError } from "../core/errors.js";
import { baseUrl, encodingOf, interceptors, timeoutOf } from "../core/transport.js";

export * from "../core/index.js";
export { connectWasm } from "./wasm.js";
export type { BrowserWasmConnectOptions } from "./wasm.js";

/** How a browser connects: the address is required, because nothing can be started. */
export interface BrowserConnectOptions extends TransportOptions {
Expand Down
3 changes: 3 additions & 0 deletions client/node/src/browser/wasm-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { serveWasmPort } from "../core/wasm.js";

serveWasmPort(globalThis);
144 changes: 144 additions & 0 deletions client/node/src/browser/wasm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { Connection } from "../core/connection.js";
import type { TransportOptions } from "../core/connection.js";
import {
connectWasmHost,
instantiateInline,
loadGoConstructor,
loadWasmSource,
WorkerWasmHost,
type WasmHost,
type WasmWorkerEndpoint,
type WasmWorkerResponse,
type WorkerWasmSource,
} from "../core/wasm.js";

/** Options for connecting to a Go WebAssembly module in a browser. */
export interface BrowserWasmConnectOptions extends TransportOptions {
/** Module URL, response, bytes, or a compiled WebAssembly module. */
wasm: string | URL | Response | ArrayBuffer | Uint8Array | WebAssembly.Module;
/** wasm_exec.js from the Go toolchain that built the module. */
wasmExec?: string | URL;
/** A worker running the package's browser WASM worker entry point. */
worker?: Worker;
version?: string;
requireCapabilities?: readonly string[];
}

/**
* Connects to sysml-wasm inline, or in the provided worker.
*/
export async function connectWasm(
options: BrowserWasmConnectOptions,
): Promise<Connection> {
const host =
options.worker === undefined
? await startInline(options.wasm, options.wasmExec)
: await startWorker(options.worker, options.wasm, options.wasmExec);
return connectWasmHost(host, options);
}

async function startInline(
wasm: BrowserWasmConnectOptions["wasm"],
wasmExec: string | URL | undefined,
): Promise<WasmHost> {
const module = await browserWasmSource(wasm);
const Go = await loadGoConstructor(wasmExec === undefined ? undefined : resourceUrl(wasmExec));
return instantiateInline(module, Go);
}

async function startWorker(
worker: Worker,
wasm: BrowserWasmConnectOptions["wasm"],
wasmExec: string | URL | undefined,
): Promise<WasmHost> {
const host = new WorkerWasmHost(browserWorkerEndpoint(worker));
try {
const { source, transfer } = await workerSource(wasm);
return await host.start(
{
type: "init",
wasm: source,
...(wasmExec === undefined ? {} : { wasmExec: resourceUrl(wasmExec) }),
},
transfer,
);
} catch (error) {
await host.close();
throw error;
}
}

function browserWorkerEndpoint(worker: Worker): WasmWorkerEndpoint {
return {
post(message, transfer) {
worker.postMessage(message, [...transfer]);
},
onMessage(listener) {
worker.addEventListener("message", (event: MessageEvent<WasmWorkerResponse>) => {
listener(event.data);
});
},
onFailure(listener) {
worker.addEventListener("error", (event: ErrorEvent) => {
listener(new Error(event.message || "the sysml-wasm worker failed"));
});
worker.addEventListener("messageerror", () => {
listener(new Error("the sysml-wasm worker sent an unreadable message"));
});
},
terminate() {
worker.terminate();
},
};
}

async function workerSource(
wasm: BrowserWasmConnectOptions["wasm"],
): Promise<{ source: WorkerWasmSource; transfer: Transferable[] }> {
if (wasm instanceof WebAssembly.Module) {
return { source: wasm, transfer: [] };
}
if (wasm instanceof Response) {
if (wasm.bodyUsed) {
throw new Error("the WebAssembly Response body has already been read");
}
const bytes = await wasm.arrayBuffer();
return { source: bytes, transfer: [bytes] };
}
if (wasm instanceof ArrayBuffer) {
const bytes = wasm.slice(0);
return { source: bytes, transfer: [bytes] };
}
if (wasm instanceof Uint8Array) {
const bytes = Uint8Array.from(wasm).buffer;
return { source: bytes, transfer: [bytes] };
}
const url = wasm instanceof URL ? wasm.href : resourceUrl(wasm);
return { source: url, transfer: [] };
}

async function browserWasmSource(
wasm: BrowserWasmConnectOptions["wasm"],
): Promise<BufferSource | WebAssembly.Module> {
if (
wasm instanceof WebAssembly.Module ||
wasm instanceof ArrayBuffer ||
wasm instanceof Uint8Array
) {
return wasm;
}
if (wasm instanceof Response && wasm.bodyUsed) {
throw new Error("the WebAssembly Response body has already been read");
}
return loadWasmSource(
wasm instanceof Response ? wasm : wasm instanceof URL ? wasm : resourceUrl(wasm),
);
}

function resourceUrl(value: string | URL): string {
if (value instanceof URL) {
return value.href;
}
const base = typeof document === "undefined" ? import.meta.url : document.baseURI;
return new URL(value, base).href;
}
1 change: 1 addition & 0 deletions client/node/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type {
ResponseTap,
TransportOptions,
} from "./connection.js";
export type { WasmHost } from "./wasm.js";
export { Instance, InstanceTree, Model, ModelSymbol, decodeDiagnostic } from "./model.js";
export type {
AttributeFacts,
Expand Down
Loading
Loading