diff --git a/.circleci/config.yml b/.circleci/config.yml index 3d3d012e4a..46f515ad18 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1912,6 +1912,11 @@ jobs: GOOS=windows GOARCH=amd64 make build-grpc VERSION="$VERSION" COMMIT="$COMMIT" BUILD_TIME="$BUILD_TIME" GO_VERSION="$GO_VERSION" mv bin/sysml-grpc dist/grpc/sysml-grpc-windows-amd64.exe + + # The Node client ships this combined WebAssembly module and matching Go runtime. + mkdir -p dist/wasm + make build-release-wasm VERSION="$VERSION" COMMIT="$COMMIT" BUILD_TIME="$BUILD_TIME" GO_VERSION="$GO_VERSION" + mv bin/wasm/release/sysml-wasm.wasm bin/wasm/release/wasm_exec.js dist/wasm/ # Create tarballs cd dist @@ -1984,7 +1989,7 @@ jobs: *) fail "$binary" "it reports '${reported}'" ;; esac done - for binary in dist/sysml-* dist/grpc/sysml-grpc-*; do + for binary in dist/sysml-* dist/grpc/sysml-grpc-* dist/wasm/sysml-wasm.wasm; do case "$binary" in *.tar.gz|*.zip|*.sha256) continue ;; dist/sysml-linux-amd64|dist/sysml-lsp-linux-amd64) continue ;; @@ -2034,6 +2039,8 @@ jobs: # the only checksum opensysml reads. (cd grpc && sha256sum sysml-grpc-* >> ../SHA256SUMS.txt) (cd grpc && for f in sysml-grpc-*; do sha256sum "$f" > "$f.sha256"; done) + (cd wasm && sha256sum sysml-wasm.wasm wasm_exec.js >> ../SHA256SUMS.txt) + (cd wasm && for f in sysml-wasm.wasm wasm_exec.js; do sha256sum "$f" > "$f.sha256"; done) cat SHA256SUMS.txt # The distribution was stamped from the binaries before the manifest @@ -2134,7 +2141,7 @@ jobs: command: | export PATH="$(go env GOPATH)/bin:$PATH" cd dist - for artifact in opensysml-linux-amd64.tar.gz grpc/sysml-grpc-linux-amd64 opensysml-*-py3-none-any.whl; do + for artifact in opensysml-linux-amd64.tar.gz grpc/sysml-grpc-linux-amd64 wasm/sysml-wasm.wasm opensysml-*-py3-none-any.whl; do cosign verify-blob-attestation "$artifact" \ --bundle provenance.intoto.json.bundle \ --type slsaprovenance1 \ @@ -2191,6 +2198,7 @@ jobs: paths: - dist/SHA256SUMS.txt - dist/grpc/*.sha256 + - dist/wasm/*.sha256 - dist/opensysml-*-py3-none-any.whl - dist/opensysml-[0-9]*.tar.gz - unless: @@ -2250,6 +2258,8 @@ jobs: echo "Publishing ${version} to the '${dist_tag}' dist-tag" # Proves the workspace carries the release binaries and sidecars. ls -l dist/grpc/sysml-grpc-* + ls -l dist/wasm/sysml-wasm.wasm dist/wasm/wasm_exec.js \ + dist/wasm/sysml-wasm.wasm.sha256 dist/wasm/wasm_exec.js.sha256 - run: name: Require the publishing token @@ -2267,17 +2277,19 @@ jobs: name: Refuse a version already on the registry command: | # The package names come from package.json, not a literal list: - # the client itself plus the optionalDependencies that carry the - # platform binaries. Anything but six means the manifest drifted. + # the client, platform binaries and optional WASM peer. Anything + # but seven means the manifest drifted. names=$(node -e " const pkg = require('./client/node/package.json'); const platforms = Object.keys(pkg.optionalDependencies || {}) .filter(n => n.startsWith(pkg.name + '-sysml-grpc-')); - console.log([pkg.name, ...platforms].join('\n')); + const wasm = Object.keys(pkg.peerDependencies || {}) + .filter(n => n.startsWith(pkg.name + '-wasm')); + console.log([pkg.name, ...platforms, ...wasm].join('\n')); ") count=$(echo "$names" | wc -l) - if [ "$count" -ne 6 ]; then - echo "Error: expected 6 packages to publish (client + 5 platform)," + if [ "$count" -ne 7 ]; then + echo "Error: expected 7 packages to publish (client + 5 platform + WASM)," echo "package.json yields $count:" echo "$names" exit 1 @@ -2303,12 +2315,12 @@ jobs: npm test - run: - name: Build the per-platform packages + name: Build the platform and WASM packages command: | # These are build-release's bytes; the generator checks each one # against its .sha256 sidecar before packaging it. cd client/node - npm run platform-packages -- --binaries ../../dist/grpc + npm run platform-packages -- --binaries ../../dist/grpc --wasm ../../dist/wasm - run: name: Authenticate to npm @@ -2323,8 +2335,9 @@ jobs: # No --provenance: the npm CLI only mints attestations on GitHub Actions and # GitLab CI/CD, and it fails rather than publish unattested when asked here. - run: - name: Publish the per-platform packages + name: Publish the WASM and per-platform packages command: | + npm publish client/node/packages/sysml-wasm --access public --tag "$NPM_DIST_TAG" for directory in client/node/packages/sysml-grpc-*; do npm publish "$directory" --access public --tag "$NPM_DIST_TAG" done @@ -2340,6 +2353,7 @@ jobs: - run: name: "REHEARSAL: pack every package" command: | + npm pack client/node/packages/sysml-wasm --dry-run for directory in client/node/packages/sysml-grpc-*; do npm pack "$directory" --dry-run done @@ -2673,6 +2687,7 @@ jobs: # The opensysml wheel, the same bytes publish-pypi uploads. mv dist/*.whl dist/release/ mv dist/grpc/* dist/release/ + mv dist/wasm/* dist/release/ mv dist/SHA256SUMS.txt dist/release/ # The signature over that manifest, which the Python client verifies # before it trusts a digest from it. @@ -2708,6 +2723,7 @@ jobs: name: "REHEARSAL: check the release assets and the GitHub token" command: | ls dist/SHA256SUMS.txt > /dev/null + ls dist/wasm/sysml-wasm.wasm dist/wasm/wasm_exec.js > /dev/null set -- dist/opensysml-*-py3-none-any.whl if [ ! -f "$1" ] || [ $# -ne 1 ]; then echo "Error: expected exactly one dist/opensysml-*-py3-none-any.whl" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index a0dc47d6dd..0fed30f5be 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -206,7 +206,11 @@ jobs: printf '%s\n' " [this workflow run]($RUN_URL). Every binary reports \`$VERSION\`." echo printf '%s' "The snapshot is replaced each night and is never the \`latest\` release; Homebrew," - printf '%s' " PyPI, npm and the Windows installer follow the stable line only. The" + printf '%s' " PyPI, npm and the Windows installer follow the stable line only." + if [[ -f dist/wasm/sysml-wasm.wasm ]]; then + printf '%s' " The \`sysml-wasm.wasm\` module and matching \`wasm_exec.js\` are available in its assets." + fi + printf '%s' " The" printf '%s' " [snapshot page](https://opensysml.org/project/nightly/) explains what it is, how" printf '%s' " to verify it and what to expect from it; the" printf '%s\n' " [install guide](https://opensysml.org/guide/01-install/) covers the stable releases." @@ -245,6 +249,28 @@ jobs: DATE: ${{ steps.version.outputs.date }} run: | sha=$(git rev-parse HEAD) + assets=( + dist/*.tar.gz + dist/*.zip + dist/opensysml-sysml.vsix + dist/SHA256SUMS.txt + dist/SHA256SUMS.txt.bundle + dist/grpc/sysml-grpc-* + ) + if [[ -f dist/wasm/sysml-wasm.wasm ]]; then + assets+=( + dist/wasm/sysml-wasm.wasm + dist/wasm/wasm_exec.js + dist/wasm/sysml-wasm.wasm.sha256 + dist/wasm/wasm_exec.js.sha256 + ) + fi + for asset in "${assets[@]}"; do + if [[ ! -f "$asset" ]]; then + printf '::error::Missing release asset: %s\n' "$asset" + exit 1 + fi + done if gh release view "$TAG" --json tagName >/dev/null 2>&1; then gh release delete "$TAG" --yes fi @@ -256,7 +282,5 @@ jobs: --notes-file notes.md \ --prerelease \ --latest=false \ - dist/*.tar.gz dist/*.zip dist/opensysml-sysml.vsix \ - dist/SHA256SUMS.txt dist/SHA256SUMS.txt.bundle \ - dist/grpc/sysml-grpc-* + "${assets[@]}" gh release view "$TAG" --json url,assets --jq '.url, (.assets[].name)' diff --git a/Makefile b/Makefile index 8ec28ac63c..e564d2bc49 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build build-sysml build-prod build-wasm-prod build-lsp build-grpc build-engine build-core build-syntax build-sysml-wasm build-wasm build-wasm-wasip1 build-wasm-js wasm-check static-check windows-versioninfo-check man man-check install-tree pgo-profile conformance conformance-pkg conformance-rust conformance-julia conformance-matlab test test-shard coverage lint clean install help fuml-expected python-test python-coverage scripts-coverage node-coverage python-install proto proto-buf python-proto proto-ts proto-rust proto-lint proto-breaking vscode-grammar vscode-build vscode-package docs docs-install docs-serve docs-engine-assets docs-counts docs-check changelog-check changelog-render self-model +.PHONY: all build build-sysml build-prod build-wasm-prod build-lsp build-grpc build-engine build-core build-syntax build-sysml-wasm build-release-wasm build-wasm build-wasm-wasip1 build-wasm-js wasm-check static-check windows-versioninfo-check man man-check install-tree pgo-profile conformance conformance-pkg conformance-rust conformance-julia conformance-matlab test test-shard coverage lint clean install help fuml-expected python-test python-coverage scripts-coverage node-coverage python-install proto proto-buf python-proto proto-ts proto-rust proto-lint proto-breaking vscode-grammar vscode-build vscode-package docs docs-install docs-serve docs-engine-assets docs-counts docs-check changelog-check changelog-render self-model # Version information # Only release tags describe a build; the moving `nightly` tag is not a version. @@ -141,6 +141,13 @@ build-sysml-wasm: ## Build bin/sysml-wasm natively (opt-in; not released) $(GO_BUILD) -o $(BIN_DIR)/sysml-wasm ./cmd/sysml-wasm @echo "✓ Built $(BIN_DIR)/sysml-wasm ($(VERSION))" +build-release-wasm: ## Build release bin/wasm/release/sysml-wasm.wasm and wasm_exec.js + @echo "Building release WebAssembly assets..." + @mkdir -p $(WASM_DIR)/release + GOOS=js GOARCH=wasm $(GO_BUILD) -trimpath -o $(WASM_DIR)/release/sysml-wasm.wasm ./cmd/sysml-wasm + @cp "$(shell go env GOROOT)/lib/wasm/wasm_exec.js" $(WASM_DIR)/release/wasm_exec.js + @echo "✓ Built $(WASM_DIR)/release/sysml-wasm.wasm and wasm_exec.js ($(VERSION))" + build-syntax: ## Build bin/sysml-syntax natively (opt-in; not released) @echo "Building sysml-syntax..." @mkdir -p $(BIN_DIR) diff --git a/changes/unreleased/sysml-wasm-release.added.md b/changes/unreleased/sysml-wasm-release.added.md new file mode 100644 index 0000000000..e898244a93 --- /dev/null +++ b/changes/unreleased/sysml-wasm-release.added.md @@ -0,0 +1,3 @@ +- **Publish the combined WebAssembly module.** Stable and nightly releases ship the module and matching Go + runtime, npm publishes them as `@openmbee/opensysml-wasm`, and Node `connectWasm()` resolves the + installed package automatically. diff --git a/client/node/README.md b/client/node/README.md index 8d80c57694..e1f2799011 100644 --- a/client/node/README.md +++ b/client/node/README.md @@ -181,11 +181,25 @@ 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: +In Node, install the optional `@openmbee/opensysml-wasm` package at the same +version as this client. `connectWasm()` resolves its module and matching Go +runtime automatically and runs a worker thread by default: + +```bash +npm install @openmbee/opensysml@ @openmbee/opensysml-wasm@ +``` ```ts import { connectWasm } from "@openmbee/opensysml"; +await using connection = await connectWasm(); +const model = await connection.loads("package Demo { part def Car; }"); +``` + +To use a module from another source, pass both the module and its matching Go +runtime: + +```ts await using connection = await connectWasm({ wasm: "./sysml-wasm.wasm", wasmExec: "/path/to/the/matching/wasm_exec.js", @@ -222,9 +236,25 @@ 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. +`@openmbee/opensysml/browser/wasm-worker`. Bundle the module and runtime from +the npm package with: + +```ts +const wasm = new URL("@openmbee/opensysml-wasm/sysml-wasm.wasm", import.meta.url); +const wasmExec = new URL("@openmbee/opensysml-wasm/wasm_exec.js", import.meta.url); +``` + +Or fetch both from jsDelivr, replacing `` with the matching package +version: + +```text +https://cdn.jsdelivr.net/npm/@openmbee/opensysml-wasm@/sysml-wasm.wasm +https://cdn.jsdelivr.net/npm/@openmbee/opensysml-wasm@/wasm_exec.js +``` + +Browser callers pass those URLs as `wasm` and `wasmExec`; browser package +resolution is not automatic. The combined module measures about 7.8 MB gzipped +and 5.5 MB with Brotli. ## Protobuf, not JSON diff --git a/client/node/package-lock.json b/client/node/package-lock.json index a5da2b8755..87189a46f7 100644 --- a/client/node/package-lock.json +++ b/client/node/package-lock.json @@ -14,6 +14,9 @@ "@connectrpc/connect-node": "2.1.2", "@connectrpc/connect-web": "2.1.2" }, + "bin": { + "opensysml-generate": "dist/node/generate-cli.js" + }, "devDependencies": { "@bufbuild/protoc-gen-es": "2.14.0", "@types/make-fetch-happen": "^10.0.4", @@ -36,6 +39,14 @@ "@sigstore/protobuf-specs": "0.5.1", "@sigstore/tuf": "4.0.2", "@sigstore/verify": "3.1.1" + }, + "peerDependencies": { + "@openmbee/opensysml-wasm": "0.9.1" + }, + "peerDependenciesMeta": { + "@openmbee/opensysml-wasm": { + "optional": true + } } }, "node_modules/@bcoe/v8-coverage": { diff --git a/client/node/package.json b/client/node/package.json index a882dc8b37..b2a6ee7dcc 100644 --- a/client/node/package.json +++ b/client/node/package.json @@ -69,6 +69,14 @@ "@sigstore/tuf": "4.0.2", "@sigstore/verify": "3.1.1" }, + "peerDependencies": { + "@openmbee/opensysml-wasm": "0.9.1" + }, + "peerDependenciesMeta": { + "@openmbee/opensysml-wasm": { + "optional": true + } + }, "devDependencies": { "@bufbuild/protoc-gen-es": "2.14.0", "@types/make-fetch-happen": "^10.0.4", diff --git a/client/node/scripts/build-platform-packages.mjs b/client/node/scripts/build-platform-packages.mjs index c6b512a364..5f23b9ce7e 100644 --- a/client/node/scripts/build-platform-packages.mjs +++ b/client/node/scripts/build-platform-packages.mjs @@ -1,8 +1,8 @@ -// Builds the per-platform npm packages that carry the sysml-grpc binary, from -// the release binaries the CI release job produces. Publishes nothing. +// Builds the per-platform sysml-grpc packages and optional WASM asset package +// from the release binaries the CI release job produces. Publishes nothing. // -// Usage: node scripts/build-platform-packages.mjs --binaries [--version X.Y.Z] [--out ] -// holds the release assets: sysml-grpc--[.exe] with .sha256 sidecars. +// Usage: node scripts/build-platform-packages.mjs --binaries [--wasm ] [--version X.Y.Z] [--out ] +// The directories hold release assets with .sha256 sidecars. import { createHash } from "node:crypto"; import { @@ -30,7 +30,12 @@ const PLATFORMS = [ ]; function parseArgs(argv) { - const args = { binaries: undefined, out: join(clientRoot, "packages"), version: undefined }; + const args = { + binaries: undefined, + wasm: undefined, + out: join(clientRoot, "packages"), + version: undefined, + }; for (let index = 0; index < argv.length; index += 2) { const flag = argv[index]; const value = argv[index + 1]; @@ -38,6 +43,7 @@ function parseArgs(argv) { fail(`${flag} needs a value`); } if (flag === "--binaries") args.binaries = resolve(value); + else if (flag === "--wasm") args.wasm = resolve(value); else if (flag === "--out") args.out = resolve(value); else if (flag === "--version") args.version = value; else fail(`unknown flag ${flag}`); @@ -119,6 +125,64 @@ function main() { built.push({ name, directory, digest }); } + if (args.wasm !== undefined) { + const assets = ["sysml-wasm.wasm", "wasm_exec.js"]; + const digests = Object.fromEntries( + assets.map((asset) => { + const source = join(args.wasm, asset); + if (!existsSync(source)) { + fail(`${source} is missing; run the release build first`); + } + return [asset, verify(source)]; + }), + ); + const name = `${args.pkg.name}-wasm`; + const directory = join(args.out, "sysml-wasm"); + mkdirSync(directory, { recursive: true }); + for (const asset of assets) { + copyFileSync(join(args.wasm, asset), join(directory, asset)); + } + writeFileSync( + join(directory, "package.json"), + `${JSON.stringify( + { + name, + version: args.version, + description: "Combined sysml-wasm WebAssembly module and matching Go runtime", + license: "Apache-2.0", + repository: { + type: "git", + url: "git+https://github.com/Open-MBEE/OpenSysML.git", + directory: "client/node", + }, + files: ["sysml-wasm.wasm", "wasm_exec.js", "README.md"], + exports: { + "./sysml-wasm.wasm": "./sysml-wasm.wasm", + "./wasm_exec.js": "./wasm_exec.js", + "./package.json": "./package.json", + }, + }, + null, + 2, + )}\n`, + ); + writeFileSync( + join(directory, "README.md"), + `# ${name}\n\n` + + "The combined `sysml-wasm` WebAssembly module and its matching Go runtime. " + + `Install this package alongside [\`${args.pkg.name}\`](https://www.npmjs.com/package/${args.pkg.name}); ` + + "Node's `connectWasm()` discovers it automatically when no module is supplied.\n\n" + + `SHA-256 of \`sysml-wasm.wasm\`: \`${digests["sysml-wasm.wasm"]}\`\n\n` + + `SHA-256 of \`wasm_exec.js\`: \`${digests["wasm_exec.js"]}\`\n`, + ); + built.push({ + name, + directory, + digest: digests["sysml-wasm.wasm"], + digests, + }); + } + writeFileSync( join(args.out, "packages.json"), `${JSON.stringify({ version: args.version, packages: built }, null, 2)}\n`, diff --git a/client/node/src/core/package.ts b/client/node/src/core/package.ts index 23f00260e2..0c9445fc26 100644 --- a/client/node/src/core/package.ts +++ b/client/node/src/core/package.ts @@ -1,5 +1,8 @@ /** The npm name this client is published under; package.json's "name" must match it. */ export const PACKAGE_NAME = "@openmbee/opensysml"; +/** The optional npm package that carries the combined WebAssembly module. */ +export const WASM_PACKAGE = `${PACKAGE_NAME}-wasm`; + /** Prefix of the per-platform packages that carry sysml-grpc: `${prefix}-`. */ export const PLATFORM_PACKAGE_PREFIX = `${PACKAGE_NAME}-sysml-grpc-`; diff --git a/client/node/src/node/wasm-source.ts b/client/node/src/node/wasm-source.ts index 71941b2552..35a004a2df 100644 --- a/client/node/src/node/wasm-source.ts +++ b/client/node/src/node/wasm-source.ts @@ -1,8 +1,24 @@ +import { existsSync, readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; -import { isAbsolute, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; +import { createRequire } from "node:module"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { OpenSysMLError } from "../core/errors.js"; +import { PACKAGE_NAME, WASM_PACKAGE } from "../core/package.js"; import type { WorkerWasmSource } from "../core/wasm.js"; +export interface NodeWasmSourceOptions { + wasm?: string | URL | Uint8Array; + wasmExec?: string | URL; +} + +export interface ResolvedNodeWasmSources { + wasm: string | URL | Uint8Array; + wasmExec: string | URL; +} + +export type PackageJsonResolver = (specifier: string) => string; + export async function loadNodeWasm( source: WorkerWasmSource | URL, ): Promise { @@ -30,6 +46,43 @@ export async function loadNodeWasm( return readFile(source); } +export function resolveWasmSources( + options: NodeWasmSourceOptions, + resolvePackageJson: PackageJsonResolver = createRequire(import.meta.url).resolve, +): ResolvedNodeWasmSources { + if (options.wasm !== undefined) { + if (options.wasmExec === undefined) { + throw new OpenSysMLError("connectWasm needs the wasmExec option when wasm is provided"); + } + return { wasm: options.wasm, wasmExec: options.wasmExec }; + } + + const assets = resolveWasmPackage(resolvePackageJson); + return { + wasm: assets.wasm, + wasmExec: options.wasmExec ?? assets.wasmExec, + }; +} + +export function resolveWasmPackage( + resolvePackageJson: PackageJsonResolver = createRequire(import.meta.url).resolve, +): { wasm: string; wasmExec: string } { + let packageJson: string; + try { + packageJson = resolvePackageJson(`${WASM_PACKAGE}/package.json`); + } catch (cause) { + throw new OpenSysMLError( + `connectWasm needs sysml-wasm.wasm: install ${WASM_PACKAGE} at ${clientVersion()}, or pass wasm and wasmExec`, + { cause }, + ); + } + const packageDirectory = dirname(packageJson); + return { + wasm: join(packageDirectory, "sysml-wasm.wasm"), + wasmExec: join(packageDirectory, "wasm_exec.js"), + }; +} + export function moduleSpecifier(source: string | URL): string { if (source instanceof URL) { return source.href; @@ -54,3 +107,28 @@ export function parseUrl(source: string): URL | undefined { return undefined; } } + +function clientVersion(): string { + let directory = dirname(fileURLToPath(import.meta.url)); + for (;;) { + const packageJson = join(directory, "package.json"); + if (existsSync(packageJson)) { + const metadata: unknown = JSON.parse(readFileSync(packageJson, "utf8")); + if ( + typeof metadata === "object" && + metadata !== null && + "name" in metadata && + metadata.name === PACKAGE_NAME && + "version" in metadata && + typeof metadata.version === "string" + ) { + return metadata.version; + } + } + const parent = dirname(directory); + if (parent === directory) { + throw new OpenSysMLError("the OpenSysML Node package version could not be found"); + } + directory = parent; + } +} diff --git a/client/node/src/node/wasm.ts b/client/node/src/node/wasm.ts index 65cd87cb58..4cf2a02ed2 100644 --- a/client/node/src/node/wasm.ts +++ b/client/node/src/node/wasm.ts @@ -10,14 +10,14 @@ import { type WasmWorkerEndpoint, type WorkerWasmSource, } from "../core/wasm.js"; -import { loadNodeWasm, moduleSpecifier } from "./wasm-source.js"; +import { loadNodeWasm, moduleSpecifier, resolveWasmSources } from "./wasm-source.js"; /** Options for connecting to a Go WebAssembly module from Node. */ export interface WasmConnectOptions extends TransportOptions { /** Path, file URL, HTTP URL, or bytes of sysml-wasm.wasm. */ - wasm: string | URL | Uint8Array; + wasm?: string | URL | Uint8Array; /** wasm_exec.js from the Go toolchain that built the module. */ - wasmExec: string | URL; + wasmExec?: string | URL; /** Run the module in a worker thread (default) or this thread. */ thread?: "worker" | "inline"; version?: string; @@ -27,20 +27,24 @@ export interface WasmConnectOptions extends TransportOptions { /** * Connects to sysml-wasm, in a worker by default or inline when requested. */ -export async function connectWasm(options: WasmConnectOptions): Promise { - const wasmExec = moduleSpecifier(options.wasmExec); +export async function connectWasm(options: WasmConnectOptions = {}): Promise { + const sources = resolveWasmSources(options); + const wasmExec = moduleSpecifier(sources.wasmExec); let host: WasmHost; if (options.thread === "inline") { - const module = await loadNodeWasm(options.wasm); + const module = await loadNodeWasm(sources.wasm); const Go = await loadGoConstructor(wasmExec); host = await instantiateInline(module, Go); } else { - host = await startWorker(options.wasm, wasmExec); + host = await startWorker(sources.wasm, wasmExec); } return connectWasmHost(host, options); } -async function startWorker(wasm: WasmConnectOptions["wasm"], wasmExec: string): Promise { +async function startWorker( + wasm: NonNullable, + wasmExec: string, +): Promise { const worker = new Worker(new URL("./wasm-worker.js", import.meta.url)); const host = new WorkerWasmHost(nodeWorkerEndpoint(worker)); const { source, transfer } = workerSource(wasm); @@ -56,7 +60,7 @@ async function startWorker(wasm: WasmConnectOptions["wasm"], wasmExec: string): } function workerSource( - wasm: WasmConnectOptions["wasm"], + wasm: NonNullable, ): { source: WorkerWasmSource; transfer: readonly ArrayBuffer[] } { if (wasm instanceof Uint8Array) { const bytes = Uint8Array.from(wasm); diff --git a/client/node/test/package.test.ts b/client/node/test/package.test.ts index 7e00d7858b..3710657e9f 100644 --- a/client/node/test/package.test.ts +++ b/client/node/test/package.test.ts @@ -5,12 +5,18 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { test } from "node:test"; -import { PACKAGE_NAME, PLATFORM_PACKAGE_PREFIX } from "../src/core/package.js"; +import { PACKAGE_NAME, PLATFORM_PACKAGE_PREFIX, WASM_PACKAGE } from "../src/core/package.js"; import { packageRoot } from "./support/service.js"; const manifest = JSON.parse( readFileSync(join(packageRoot, "package.json"), "utf8"), -) as { name: string; version: string; optionalDependencies: Record }; +) as { + name: string; + version: string; + optionalDependencies: Record; + peerDependencies: Record; + peerDependenciesMeta: Record; +}; test("package.json names the package PACKAGE_NAME describes", () => { assert.equal(manifest.name, PACKAGE_NAME); @@ -37,3 +43,9 @@ test("optionalDependencies name exactly the five platform packages at this versi assert.equal(version, manifest.version); } }); + +test("the optional WASM peer package matches the client version", () => { + assert.equal(WASM_PACKAGE, `${PACKAGE_NAME}-wasm`); + assert.equal(manifest.peerDependencies[WASM_PACKAGE], manifest.version); + assert.equal(manifest.peerDependenciesMeta[WASM_PACKAGE].optional, true); +}); diff --git a/client/node/test/platform-packages.test.ts b/client/node/test/platform-packages.test.ts index 55f826a0d0..35116db716 100644 --- a/client/node/test/platform-packages.test.ts +++ b/client/node/test/platform-packages.test.ts @@ -4,11 +4,17 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { PLATFORM_PACKAGE_PREFIX } from "../src/core/package.js"; +import { PACKAGE_NAME, PLATFORM_PACKAGE_PREFIX, WASM_PACKAGE } from "../src/core/package.js"; import { packageRoot } from "./support/service.js"; const SCRIPT = join(packageRoot, "scripts", "build-platform-packages.mjs"); @@ -19,6 +25,7 @@ const ASSETS = [ "sysml-grpc-darwin-arm64", "sysml-grpc-windows-amd64.exe", ]; +const WASM_ASSETS = ["sysml-wasm.wasm", "wasm_exec.js"]; test("every platform package is built, described and digest-checked", () => { const binaries = fakeRelease(); @@ -75,15 +82,109 @@ test("a binary with no published digest is refused rather than trusted", () => { assert.match(result.stderr, /sha256 is missing/); }); -function run(binaries: string, out: string): { status: number | null; stderr: string } { +test("the optional WASM package carries the exact release assets and exports", () => { + const binaries = fakeRelease(); + const wasm = fakeWasm(); + const out = mkdtempSync(join(tmpdir(), "packages-")); + const client = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as { + peerDependencies: Record; + version: string; + }; + const result = run(binaries, out, wasm, client.version); + assert.equal(result.status, 0, result.stderr); + + const manifest = JSON.parse(readFileSync(join(out, "packages.json"), "utf8")) as { + packages: { name: string; directory: string; digests?: Record }[]; + }; + const entry = manifest.packages.find(({ name }) => name === WASM_PACKAGE); + assert.ok(entry); + assert.deepEqual(Object.keys(entry.digests ?? {}).sort(), [...WASM_ASSETS].sort()); + + assert.deepEqual( + Object.keys(client.peerDependencies).filter((name) => name.startsWith(`${PACKAGE_NAME}-wasm`)), + [entry.name], + ); + + const packageJson = JSON.parse(readFileSync(join(entry.directory, "package.json"), "utf8")) as { + name: string; + version: string; + files: string[]; + exports: Record; + os?: string[]; + cpu?: string[]; + }; + assert.equal(packageJson.name, WASM_PACKAGE); + assert.equal(packageJson.version, client.version); + assert.deepEqual(packageJson.files, [...WASM_ASSETS, "README.md"]); + assert.deepEqual(packageJson.exports, { + "./sysml-wasm.wasm": "./sysml-wasm.wasm", + "./wasm_exec.js": "./wasm_exec.js", + "./package.json": "./package.json", + }); + assert.equal(packageJson.os, undefined); + assert.equal(packageJson.cpu, undefined); + assert.deepEqual( + readdirSync(entry.directory).sort(), + [...WASM_ASSETS, "README.md", "package.json"].sort(), + ); + const readme = readFileSync(join(entry.directory, "README.md"), "utf8"); + for (const asset of WASM_ASSETS) { + const bytes: Uint8Array = readFileSync(join(entry.directory, asset)); + const digest: string = createHash("sha256").update(bytes).digest("hex"); + assert.equal(entry.digests?.[asset], digest); + assert.ok(readme.includes(digest)); + } +}); + +test("the WASM package refuses a mismatched asset sidecar", () => { + const wasm = fakeWasm(); + writeFileSync(join(wasm, `${WASM_ASSETS[0]}.sha256`), `${"0".repeat(64)} ${WASM_ASSETS[0]}\n`); + const result = run(fakeRelease(), mkdtempSync(join(tmpdir(), "packages-")), wasm); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /hashes to/); +}); + +test("the WASM package refuses a missing asset sidecar", () => { + const result = run( + fakeRelease(), + mkdtempSync(join(tmpdir(), "packages-")), + fakeWasm({ sidecars: false }), + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /sha256 is missing/); +}); + +function run( + binaries: string, + out: string, + wasm?: string, + version = "0.0.0-test", +): { status: number | null; stderr: string } { + const args = [SCRIPT, "--binaries", binaries, "--out", out, "--version", version]; + if (wasm !== undefined) { + args.push("--wasm", wasm); + } const result = spawnSync( process.execPath, - [SCRIPT, "--binaries", binaries, "--out", out, "--version", "0.0.0-test"], + args, { encoding: "utf8" }, ); return { status: result.status, stderr: result.stderr }; } +function fakeWasm(options: { sidecars?: boolean } = {}): string { + const dir = mkdtempSync(join(tmpdir(), "wasm-assets-")); + for (const asset of WASM_ASSETS) { + const bytes = Buffer.from(`release asset: ${asset}`); + writeFileSync(join(dir, asset), bytes); + if (options.sidecars !== false) { + const digest = createHash("sha256").update(bytes).digest("hex"); + writeFileSync(join(dir, `${asset}.sha256`), `${digest} ${asset}\n`); + } + } + return dir; +} + /** A directory shaped like a release: one file per asset, with its .sha256 sidecar. */ function fakeRelease(options: { sidecars?: boolean } = {}): string { const dir = mkdtempSync(join(tmpdir(), "release-")); diff --git a/client/node/test/wasm.test.ts b/client/node/test/wasm.test.ts index 9404ac8b40..b529787ea2 100644 --- a/client/node/test/wasm.test.ts +++ b/client/node/test/wasm.test.ts @@ -1,5 +1,11 @@ import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; import { readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -31,6 +37,8 @@ import { type WasmWorkerRequest, type WasmWorkerResponse, } from "../src/core/wasm.js"; +import { WASM_PACKAGE } from "../src/core/package.js"; +import { resolveWasmSources } from "../src/node/wasm-source.js"; import { SysMLService } from "../src/generated/sysml_pb.js"; import { wasmArtifacts, type WasmArtifacts } from "./support/wasm.js"; import { repoRoot, SAMPLE, useServiceBinary } from "./support/service.js"; @@ -353,6 +361,55 @@ test("Go constructor load failures are not cached", async () => { } }); +test("Node WASM package resolution uses package-local assets", () => { + const directory = mkdtempSync(join(tmpdir(), "opensysml-wasm-package-")); + const packageDirectory = join(directory, "node_modules", ...WASM_PACKAGE.split("/")); + mkdirSync(packageDirectory, { recursive: true }); + const packageJson = join(packageDirectory, "package.json"); + writeFileSync(packageJson, JSON.stringify({ name: WASM_PACKAGE, version: "0.0.0" })); + writeFileSync(join(packageDirectory, "sysml-wasm.wasm"), "wasm"); + writeFileSync(join(packageDirectory, "wasm_exec.js"), "runtime"); + + const resolver = (specifier: string): string => { + assert.equal(specifier, `${WASM_PACKAGE}/package.json`); + return packageJson; + }; + try { + assert.deepEqual(resolveWasmSources({}, resolver), { + wasm: join(packageDirectory, "sysml-wasm.wasm"), + wasmExec: join(packageDirectory, "wasm_exec.js"), + }); + assert.deepEqual(resolveWasmSources({ wasmExec: "custom-runtime.js" }, resolver), { + wasm: join(packageDirectory, "sysml-wasm.wasm"), + wasmExec: "custom-runtime.js", + }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("Node WASM resolution reports the optional package installation command", () => { + assert.throws( + () => + resolveWasmSources({}, () => { + throw new Error("not installed"); + }), + (error: unknown) => + error instanceof OpenSysMLError && + new RegExp( + `^connectWasm needs sysml-wasm\\.wasm: install ${WASM_PACKAGE.replace("/", "\\/")} at \\d+\\.\\d+\\.\\d+, or pass wasm and wasmExec$`, + ).test(error.message), + ); +}); + +test("Node WASM resolution requires wasmExec with a supplied module", () => { + assert.throws( + () => resolveWasmSources({ wasm: emptyWasm }, () => "unused"), + (error: unknown) => + error instanceof OpenSysMLError && error.message.includes("wasmExec"), + ); +}); + test("worker calls remove aborted pending entries and ignore late answers", async () => { const posted: WasmWorkerRequest[] = []; let onMessage: ((message: WasmWorkerResponse) => void) | undefined; @@ -600,6 +657,33 @@ for (const thread of ["worker", "inline"] as const) { }); } +test("Node connectWasm resolves real assets from the optional package", { skip: wasmSkip }, async () => { + const wasmFiles = requireArtifacts(); + const directory = mkdtempSync(join(tmpdir(), "opensysml-wasm-package-")); + const packageDirectory = join(directory, "node_modules", ...WASM_PACKAGE.split("/")); + mkdirSync(packageDirectory, { recursive: true }); + copyFileSync(wasmFiles.wasm, join(packageDirectory, "sysml-wasm.wasm")); + copyFileSync(wasmFiles.wasmExec, join(packageDirectory, "wasm_exec.js")); + const packageJson = join(packageDirectory, "package.json"); + writeFileSync(packageJson, JSON.stringify({ name: WASM_PACKAGE, version: "0.0.0" })); + const sources = resolveWasmSources({}, (specifier) => { + assert.equal(specifier, `${WASM_PACKAGE}/package.json`); + return packageJson; + }); + const wasm = await connectWasm(sources); + try { + await using native = await connect(); + const wasmModel = await wasm.loads(SAMPLE); + const nativeModel = await native.loads(SAMPLE); + assert.equal(wasmModel.hash, nativeModel.hash); + assert.deepEqual(wasmModel.diagnostics, nativeModel.diagnostics); + assert.deepEqual(await wasmModel.eval("2 + 2"), await nativeModel.eval("2 + 2")); + } finally { + await wasm.close(); + rmSync(directory, { recursive: true, force: true }); + } +}); + test("Node WASM workers terminate on close and stale-version refusal", { skip: wasmSkip }, async () => { const originalTerminate = Object.getOwnPropertyDescriptor(WorkerPrototype, "terminate") ?.value as ((this: NodeWorker) => Promise) | undefined; diff --git a/client/python/scripts/check_version.py b/client/python/scripts/check_version.py index 4bd6485c41..71ac0cd293 100644 --- a/client/python/scripts/check_version.py +++ b/client/python/scripts/check_version.py @@ -234,8 +234,8 @@ def node_version(declared=None, node=None, tag=None): node, declared, tag, - 'set "version" and every platform package in optionalDependencies to the ' - "SemVer spelling of that version.", + 'set "version", every platform package in optionalDependencies and ' + "@openmbee/opensysml-wasm in peerDependencies to the SemVer spelling of that version.", ) diff --git a/docs/project/nightly.md b/docs/project/nightly.md index fd98a260e0..3ebaab5b4f 100644 --- a/docs/project/nightly.md +++ b/docs/project/nightly.md @@ -2,8 +2,9 @@ [![Nightly snapshot](https://github.com/Open-MBEE/OpenSysML/actions/workflows/nightly.yml/badge.svg?branch=develop)](https://github.com/Open-MBEE/OpenSysML/actions/workflows/nightly.yml) -Every night the newest green commit on `develop` is built into the same binaries a -release ships and published as the prerelease **[`nightly`](https://github.com/Open-MBEE/OpenSysML/releases/tag/nightly)**. +Every night the newest green commit on `develop` is built into the same binaries +and WebAssembly assets a release ships and published as the prerelease +**[`nightly`](https://github.com/Open-MBEE/OpenSysML/releases/tag/nightly)**. It is a development build: what a change looks like the day it lands, before the next stable release ([latest](https://github.com/Open-MBEE/OpenSysML/releases/latest), installed as described in the [install guide](../guide/01-install.md)) carries it. @@ -24,6 +25,7 @@ described in the [install guide](../guide/01-install.md)) carries it. compare against the last stable tag. The walk ends at the commit the current snapshot was built from, so the snapshot never moves backwards, and a green commit that predates `scripts/build-release-artifacts.sh` is skipped, since the workflow cannot build it. + Snapshots built from commits predating the WASM build do not include WebAssembly assets. - **Replaced, not accumulated.** There is one snapshot. Each night the previous release is deleted, the `nightly` tag moved, and a new release published with only that night's assets. A link to `releases/tag/nightly` is stable; a link to an asset of a particular night @@ -48,8 +50,13 @@ The assets are the ones a stable release ships, laid out the same way (see darwin/arm64 and windows/amd64; - `sysml--.tar.gz` and `sysml-lsp--.tar.gz` — each binary on its own; - `sysml-grpc--` with a `.sha256` sidecar — the gRPC service, raw; +- `wasm/sysml-wasm.wasm` and `wasm/wasm_exec.js` with `.sha256` sidecars — the + combined WebAssembly module and matching Go runtime; - `SHA256SUMS.txt` over all of the above and its cosign bundle `SHA256SUMS.txt.bundle`. +The nightly checksum manifest is cosign-signed; nightly assets do not have SLSA +provenance, just like the other assets in the snapshot. + And one a stable release does not ship: - `opensysml-sysml.vsix` — the [VS Code extension](../guide/08-editors.md#vs-code) packaged diff --git a/docs/project/releasing.md b/docs/project/releasing.md index 451293632d..ef260f73bc 100644 --- a/docs/project/releasing.md +++ b/docs/project/releasing.md @@ -2,8 +2,9 @@ A release is cut by pushing a `v*` tag. Everything after that is CircleCI: the `release` workflow runs the test suite, cross-compiles `sysml`, `sysml-lsp` and -`sysml-grpc` for five platforms, builds the Python client's wheel and sdist, and -publishes all of them to a GitHub release and the package to PyPI. Nothing is +`sysml-grpc` for five platforms, builds the combined `sysml-wasm` module and the +Python client's wheel and sdist; release assets go to GitHub and the Python +package to PyPI. Nothing is published from a laptop. The Python client is released in lockstep with the core: the same `v` tag @@ -151,8 +152,9 @@ branch that moves the integration state onto `main`: well: the tag publishes `opensysml` at the core version, and the release workflow fails before building anything when the two disagree (see [Releasing opensysml to PyPI](#releasing-opensysml-to-pypi)). Also set `"version"` in - `client/node/package.json` — and the five platform packages in - `optionalDependencies` — to the SemVer spelling of the same version (`0.9.1`; + `client/node/package.json` — the five platform packages in + `optionalDependencies` and `@openmbee/opensysml-wasm` in `peerDependencies` — + to the SemVer spelling of the same version (`0.9.1`; `0.9.0-rc.1` for `0.9.0rc1`), and run `npm install --package-lock-only` in `client/node` so the lockfile agrees; the release workflow fails before building anything when package.json disagrees. `client/java/pom.xml` follows @@ -237,11 +239,14 @@ so that `dist/` holds: - `sysml-grpc--`, published raw with a `.sha256` sidecar rather than archived, because that is what `opensysml` downloads and verifies (`client/python/opensysml/binary.py`) when it starts the service for a Python caller; +- `wasm/sysml-wasm.wasm` and `wasm/wasm_exec.js`, the combined WebAssembly + module and matching Go runtime, each with a `.sha256` sidecar; - the Python client's distribution, `opensysml--py3-none-any.whl` and `opensysml-.tar.gz`, built by `build-python-package` from those `sysml-grpc` binaries' digests and the same files `publish-pypi` uploads (see [Releasing opensysml to PyPI](#releasing-opensysml-to-pypi)); -- `SHA256SUMS.txt` over every archive, the wheel and every `sysml-grpc` binary, +- `SHA256SUMS.txt` over every archive, the wheel, every `sysml-grpc` binary and + both WebAssembly assets, with its cosign signature `SHA256SUMS.txt.bundle` (see [The signed checksum manifest](#the-signed-checksum-manifest)); - `provenance.intoto.json`, the SLSA provenance statement naming every artifact @@ -284,10 +289,10 @@ the GitHub release means the package version never exists without the release it names; if the GitHub upload fails, nothing irreversible has happened yet. `publish-npm` runs beside it, also after the GitHub release and also not -repeatable: npm never accepts a version twice. It publishes the five +repeatable: npm never accepts a version twice. It publishes +`@openmbee/opensysml-wasm` from `dist/wasm` and the five `@openmbee/opensysml-sysml-grpc--` platform packages built from -`build-release`'s `dist/grpc` binaries — the same bytes the release ships — and -then the `@openmbee/opensysml` client (see +`build-release`'s release assets, then the `@openmbee/opensysml` client (see [Releasing @openmbee/opensysml to npm](#releasing-openmbeeopensysml-to-npm)). `publish-maven` runs beside them, in the same position and with the same @@ -368,11 +373,12 @@ one alongside it). "import opensysml; print(opensysml.__version__, opensysml.load('examples/state-machine-demo.sysml').diagnostics)" ``` -2. **Verify the npm upload.** Check the registry sees all six packages at the +2. **Verify the npm upload.** Check the registry sees all seven packages at the version and the right dist-tag: ```bash npm view @openmbee/opensysml@0.0.5 version dist-tags + npm view @openmbee/opensysml-wasm@0.0.5 version ``` Then install it in a temp dir and load a model with `OPENSYSML_BINARY` unset, @@ -1145,11 +1151,12 @@ The npm packages are published with core releases at the client's version, and the `npm` context they need is already in place (see [What the job needs](#what-the-job-needs-1)). -### Six packages, one tag +### Seven packages, one tag `@openmbee/opensysml` carries no binary. The service binary comes from one of five per-platform packages it names in `optionalDependencies`, which npm installs by -matching their `os`/`cpu` metadata: +matching their `os`/`cpu` metadata. The optional `@openmbee/opensysml-wasm` peer +package carries the combined WebAssembly module and its matching Go runtime: | package | os | cpu | | --- | --- | --- | @@ -1158,11 +1165,12 @@ matching their `os`/`cpu` metadata: | `@openmbee/opensysml-sysml-grpc-darwin-x64` | darwin | x64 | | `@openmbee/opensysml-sysml-grpc-darwin-arm64` | darwin | arm64 | | `@openmbee/opensysml-sysml-grpc-win32-x64` | win32 | x64 | +| `@openmbee/opensysml-wasm` | — | — | -All six share the version in `client/node/package.json`, because the -`optionalDependencies` name that exact version. The platform packages are -published first, so `@openmbee/opensysml` is never on the registry naming a -version of them that is not. Where no package matches — a platform with no +All seven share the version in `client/node/package.json`. The platform +packages and WASM package are published first, so `@openmbee/opensysml` is +never on the registry naming a version of a package that is not. Where no +platform package matches — a platform with no release build — the client falls back to `$OPENSYSML_BINARY`, a binary in `~/.opensysml/bin/`, a release download into that cache, `sysml-grpc` on `$PATH`, or an explicit external service. That download is the Python client's: @@ -1177,8 +1185,10 @@ The five binaries are `build-release-binaries`' `dist/grpc` output, with the GitHub release and the signed `SHA256SUMS.txt`, persisted to the workspace the npm job attaches. `npm run platform-packages` refuses to package a binary whose bytes disagree with its `.sha256` sidecar, or that has none, so the -packages can only carry what the release built. npm's `--provenance` is not -used: the CLI mints attestations only on GitHub Actions and GitLab CI/CD. +packages can only carry what the release built. The WASM package is built from +`dist/wasm` in the same workspace with both assets checked against their +sidecars. npm's `--provenance` is not used: the CLI mints attestations only on +GitHub Actions and GitLab CI/CD. ### Why the core's tag @@ -1186,8 +1196,10 @@ The client follows the Python client's choice (see [Why the same tag](#why-the-same-tag)): every npm version then has a core release of the same version tested with it in the same pipeline, and a caller pins one number — `npm install @openmbee/opensysml@0.9.1` gets the release's own -binary via the platform package. The cost: a client-only fix is a core patch -release. And since an npm publish is irreversible, the job runs last and refuses +binary via the platform package; install `@openmbee/opensysml-wasm` at the same +version for Node's automatic `connectWasm()` package resolution. The cost: a +client-only fix is a core patch release. And since an npm publish is +irreversible, the job runs last and refuses a version already on the registry, just like `publish-pypi`. ### The version @@ -1196,13 +1208,14 @@ a version already on the registry, just like `publish-pypi`. same version, spelled the SemVer way (`0.9.0-rc.1` for `0.9.0rc1`). `check_version.py --node` in `build-python-package` fails the release before anything is built when they disagree, and the pytest gate in -`test_check_version.py` runs on every PR that touches either file. The tag must +`test_check_version.py` runs on every PR that touches either file. The Node +package tests also ensure the optional WASM peer follows the client version. The tag must spell the SemVer version exactly, `v` aside. ### Pre-releases A pre-release tag — the same one that sends `opensysml` to TestPyPI — publishes -all six packages to the `next` dist-tag; `latest` is untouched. Install a +all seven packages to the `next` dist-tag; `latest` is untouched. Install a pre-release with `@next` or the exact version. ### What the job needs @@ -1233,16 +1246,16 @@ Everything below is already in place; it is recorded so it can be re-created. `client/node/package.json`, picks the `latest`/`next` dist-tag from the version, and lists the workspace binaries it will package. 2. Requires `NPM_TOKEN` from the `npm` context. -3. Refuses to run if any of the six packages is already on the registry at this +3. Refuses to run if any of the seven packages is already on the registry at this version (a publish cannot be repeated). 4. Builds and tests the client against the release's linux binary (`npm ci`, build, typecheck, lint, tests). -5. Builds the five platform packages from `dist/grpc`, checking each binary - against its `.sha256` sidecar. +5. Builds the five platform packages from `dist/grpc` and the WASM package + from `dist/wasm`, checking every asset against its `.sha256` sidecar. 6. Authenticates to npm and runs `npm whoami`, so an expired token fails before the first publish. -7. Publishes the five platform packages, then the client, on the resolved - dist-tag. +7. Publishes the WASM and five platform packages, then the client, on the + resolved dist-tag. ### If a publish goes wrong diff --git a/docs/reference/wasm.md b/docs/reference/wasm.md index 8928850396..e868ae7bf3 100644 --- a/docs/reference/wasm.md +++ b/docs/reference/wasm.md @@ -4,9 +4,12 @@ OpenSysML is Go, and Go compiles it for two WebAssembly targets. This page says 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. -No WebAssembly artifact ships in a release: releases are native binaries for Linux, macOS and -Windows. The WebAssembly builds are built from source, for a host that runs modules rather than -executables. +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 @@ -34,7 +37,8 @@ beside it. `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, and no release ships them. +`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, @@ -213,6 +217,8 @@ Measured on a `go1.25` `js/wasm` build: 20,601,256 raw bytes, 5,444,030 bytes wi The Node and browser client adapter is documented in [WebAssembly, without a service](../../client/node/README.md#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`, diff --git a/scripts/build-release-artifacts.sh b/scripts/build-release-artifacts.sh index 75667c8815..fa594c7a06 100755 --- a/scripts/build-release-artifacts.sh +++ b/scripts/build-release-artifacts.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # -# Cross-compile sysml, sysml-lsp and sysml-grpc for every released platform and +# Cross-compile sysml, sysml-lsp and sysml-grpc for every released platform, +# and sysml-wasm for JavaScript, # lay them out as release assets, the way the CircleCI `build-release` job does. # # Usage: VERSION= scripts/build-release-artifacts.sh [dist-dir] @@ -13,6 +14,7 @@ # sysml--.tar.gz, sysml-lsp--.tar.gz (.zip on Windows) # opensysml--.tar.gz (.zip on Windows) # grpc/sysml-grpc--[.exe] with a .sha256 sidecar +# wasm/sysml-wasm.wasm and wasm/wasm_exec.js with .sha256 sidecars # SHA256SUMS.txt # # Every binary is then checked for the version it should report: the host @@ -45,6 +47,7 @@ build() { # rm -rf "$DIST" mkdir -p "$DIST/grpc" +mkdir -p "$DIST/wasm" for platform in "${PLATFORMS[@]}"; do build build-sysml sysml "$platform" "$DIST/sysml-${platform}" @@ -54,6 +57,10 @@ for platform in "${PLATFORMS[@]}"; do build build-grpc sysml-grpc "$platform" "$DIST/grpc/sysml-grpc-${platform}" done +make build-release-wasm \ + VERSION="$VERSION" COMMIT="$COMMIT" BUILD_TIME="$BUILD_TIME" GO_VERSION="$GO_VERSION" +mv bin/wasm/release/sysml-wasm.wasm bin/wasm/release/wasm_exec.js "$DIST/wasm/" + cd "$DIST" "$CHECK_STATIC" sysml-linux-* sysml-lsp-linux-* grpc/sysml-grpc-linux-* @@ -90,6 +97,8 @@ rm -rf stage sha256sum ./*.tar.gz ./*.zip | sed 's|\./||' > SHA256SUMS.txt (cd grpc && sha256sum sysml-grpc-* >> ../SHA256SUMS.txt) (cd grpc && for f in sysml-grpc-*; do sha256sum "$f" > "$f.sha256"; done) +(cd wasm && sha256sum sysml-wasm.wasm wasm_exec.js >> ../SHA256SUMS.txt) +(cd wasm && for f in sysml-wasm.wasm wasm_exec.js; do sha256sum "$f" > "$f.sha256"; done) cat SHA256SUMS.txt host="$(go env GOHOSTOS)-$(go env GOHOSTARCH)" @@ -99,7 +108,7 @@ fail() { echo "The version ldflags (see the Makefile's LDFLAGS) did not reach this build." >&2 status=1 } -for binary in sysml-* grpc/sysml-grpc-*; do +for binary in sysml-* grpc/sysml-grpc-* wasm/sysml-wasm.wasm; do if [[ "$binary" == *.tar.gz || "$binary" == *.zip || "$binary" == *.sha256 ]]; then continue fi