Replace the dev server's wasm-pack, cargo-watch, and concurrently tools with custom cargo-run tooling (#4254)

Replace wasm-pack, cargo-watch and concurrently with custom build tooling

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
Timon
2026-06-21 19:23:08 +00:00
committed by Keavon Chambers
parent 1f05000238
commit a72ac39059
22 changed files with 978 additions and 479 deletions

View File

@@ -6,7 +6,7 @@
},
"ghcr.io/devcontainers/features/node:1": {}
},
"onCreateCommand": "cargo install cargo-watch wasm-pack cargo-about && cargo install -f wasm-bindgen-cli@0.2.121",
"onCreateCommand": "cargo install cargo-about && cargo install -f wasm-bindgen-cli@0.2.121",
"customizations": {
"vscode": {
// NOTE: Keep this in sync with `.vscode/extensions.json`

View File

@@ -58,6 +58,8 @@ jobs:
RUSTC_WRAPPER: /usr/bin/sccache
CARGO_INCREMENTAL: 0
SCCACHE_DIR: /var/lib/github-actions/.cache
WASM_BINDGEN_CLI_VERSION: "0.2.121"
BINARYEN_VERSION: "130"
steps:
- name: 📥 Clone repository
uses: actions/checkout@v6
@@ -65,19 +67,11 @@ jobs:
repository: ${{ inputs.checkout_repo || github.repository }}
ref: ${{ inputs.checkout_ref || '' }}
- name: 🗑 Clear wasm-bindgen cache
run: rm -r ~/.cache/.wasm-pack || true
- name: 🟢 Install Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: 🚧 Install build dependencies
run: |
cd frontend
npm run setup
- name: 🦀 Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
@@ -87,6 +81,19 @@ jobs:
rustflags: ""
target: wasm32-unknown-unknown
- name: 🚧 Install wasm-bindgen-cli and Binaryen wasm-opt
run: |
if ! wasm-bindgen --version 2>/dev/null | grep -qF "$WASM_BINDGEN_CLI_VERSION"; then
cargo install -f "wasm-bindgen-cli@$WASM_BINDGEN_CLI_VERSION"
fi
BINARYEN_DIR="$HOME/.cache/binaryen-version_$BINARYEN_VERSION"
if [ ! -x "$BINARYEN_DIR/bin/wasm-opt" ]; then
mkdir -p "$BINARYEN_DIR"
curl -sSfL "https://github.com/WebAssembly/binaryen/releases/download/version_$BINARYEN_VERSION/binaryen-version_$BINARYEN_VERSION-x86_64-linux.tar.gz" | tar xz -C "$BINARYEN_DIR" --strip-components=1
fi
echo "$BINARYEN_DIR/bin" >> "$GITHUB_PATH"
- name: 🔀 Choose production deployment environment and insert template
id: production-env
if: github.event_name == 'push'
@@ -294,6 +301,7 @@ jobs:
env:
WASM_BINDGEN_CLI_VERSION: "0.2.121"
BINARYEN_VERSION: "130"
steps:
- name: 📥 Clone repository
@@ -341,13 +349,15 @@ jobs:
winget install --id LLVM.LLVM -e --accept-package-agreements --accept-source-agreements
winget install --id Kitware.CMake -e --accept-package-agreements --accept-source-agreements
winget install --id OpenSSL.OpenSSL -e --accept-package-agreements --accept-source-agreements
winget install --id WebAssembly.Binaryen -e --accept-package-agreements --accept-source-agreements
winget install --id GnuWin32.PkgConfig -e --accept-package-agreements --accept-source-agreements
"OPENSSL_DIR=C:\Program Files\OpenSSL-Win64" | Out-File -FilePath $env:GITHUB_ENV -Append
"PKG_CONFIG_PATH=C:\Program Files\OpenSSL-Win64\lib\pkgconfig" | Out-File -FilePath $env:GITHUB_ENV -Append
cargo binstall --no-confirm --force wasm-pack
curl.exe -sSfL -o "$env:RUNNER_TEMP\binaryen.tar.gz" "https://github.com/WebAssembly/binaryen/releases/download/version_$env:BINARYEN_VERSION/binaryen-version_$env:BINARYEN_VERSION-x86_64-windows.tar.gz"
tar -xzf "$env:RUNNER_TEMP\binaryen.tar.gz" -C $env:RUNNER_TEMP
"$env:RUNNER_TEMP\binaryen-version_$env:BINARYEN_VERSION\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
cargo binstall --no-confirm --force cargo-about
cargo binstall --no-confirm --force "wasm-bindgen-cli@$env:WASM_BINDGEN_CLI_VERSION"
@@ -485,6 +495,7 @@ jobs:
env:
WASM_BINDGEN_CLI_VERSION: "0.2.121"
BINARYEN_VERSION: "130"
steps:
- name: 📥 Clone repository
@@ -529,7 +540,6 @@ jobs:
brew install \
pkg-config \
openssl@3 \
binaryen \
llvm \
cargo-binstall
@@ -537,7 +547,13 @@ jobs:
echo "PKG_CONFIG_PATH=$(brew --prefix openssl@3)/lib/pkgconfig" >> $GITHUB_ENV
echo "$(brew --prefix llvm)/bin" >> $GITHUB_PATH
cargo binstall --no-confirm --force wasm-pack
BINARYEN_DIR="$HOME/.cache/binaryen-version_$BINARYEN_VERSION"
if [ ! -x "$BINARYEN_DIR/bin/wasm-opt" ]; then
mkdir -p "$BINARYEN_DIR"
curl -sSfL "https://github.com/WebAssembly/binaryen/releases/download/version_$BINARYEN_VERSION/binaryen-version_$BINARYEN_VERSION-arm64-macos.tar.gz" | tar xz -C "$BINARYEN_DIR" --strip-components=1
fi
echo "$BINARYEN_DIR/bin" >> "$GITHUB_PATH"
cargo binstall --no-confirm --force cargo-about
cargo binstall --no-confirm --force "wasm-bindgen-cli@${WASM_BINDGEN_CLI_VERSION}"

View File

@@ -30,7 +30,6 @@ pkgs.mkShell (
pkgs.nodejs
pkgs.binaryen
pkgs.wasm-bindgen-cli_0_2_121
pkgs.wasm-pack
pkgs.cargo-about
pkgs.rustc
@@ -41,7 +40,6 @@ pkgs.mkShell (
pkgs.git
pkgs.cargo-watch
pkgs.cargo-nextest
pkgs.cargo-expand

View File

@@ -84,7 +84,6 @@ deps.crane.lib.buildPackage (
pkgs.nodejs
pkgs.binaryen
pkgs.wasm-bindgen-cli_0_2_121
pkgs.wasm-pack
pkgs.cargo-about
pkgs.removeReferencesTo
pkgs.importNpmLock.npmConfigHook
@@ -94,7 +93,6 @@ deps.crane.lib.buildPackage (
npmRoot = "${info.src}/frontend";
};
npmRoot = "frontend";
npmConfigScript = "setup";
makeCacheWritable = true;
env = {

180
Cargo.lock generated
View File

@@ -505,9 +505,20 @@ dependencies = [
name = "cargo-run"
version = "0.0.0"
dependencies = [
"duct",
"semver",
"thiserror 2.0.18",
]
[[package]]
name = "cargo-run-internal-watch"
version = "0.0.0"
dependencies = [
"cargo-run",
"notify",
"notify-debouncer-full",
]
[[package]]
name = "cargo-util-schemas"
version = "0.8.2"
@@ -1335,6 +1346,18 @@ dependencies = [
"dtoa",
]
[[package]]
name = "duct"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e66e9c0c03d094e1a0ba1be130b849034aa80c3a2ab8ee94316bc809f3fa684"
dependencies = [
"libc",
"os_pipe",
"shared_child",
"shared_thread",
]
[[package]]
name = "dunce"
version = "1.0.5"
@@ -1514,6 +1537,15 @@ dependencies = [
"log",
]
[[package]]
name = "file-id"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "filetime"
version = "0.2.26"
@@ -1637,6 +1669,15 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "futf"
version = "0.1.5"
@@ -2797,6 +2838,26 @@ dependencies = [
"rustversion",
]
[[package]]
name = "inotify"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1"
dependencies = [
"bitflags 2.11.0",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "interpreted-executor"
version = "0.1.0"
@@ -2992,6 +3053,26 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kqueue"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [
"bitflags 2.11.0",
"libc",
]
[[package]]
name = "kurbo"
version = "0.13.1"
@@ -3257,6 +3338,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
dependencies = [
"libc",
"log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.59.0",
]
@@ -3433,6 +3515,46 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "notify"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags 2.11.0",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio",
"notify-types",
"walkdir",
"windows-sys 0.60.2",
]
[[package]]
name = "notify-debouncer-full"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c02b49179cfebc9932238d04d6079912d26de0379328872846118a0fa0dbb302"
dependencies = [
"file-id",
"log",
"notify",
"notify-types",
"walkdir",
]
[[package]]
name = "notify-types"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.1"
@@ -3807,6 +3929,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "owned_ttf_parser"
version = "0.25.1"
@@ -5207,12 +5339,60 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shared_child"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7"
dependencies = [
"libc",
"sigchld",
"windows-sys 0.60.2",
]
[[package]]
name = "shared_thread"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b86057fcb5423f5018e331ac04623e32d6b5ce85e33300f92c79a1973928b0"
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "sigchld"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1"
dependencies = [
"libc",
"os_pipe",
"signal-hook",
]
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.7"

View File

@@ -27,6 +27,7 @@ members = [
"node-graph/preprocessor",
"proc-macros",
"tools/cargo-run",
"tools/cargo-run/internal/*",
"tools/crate-hierarchy-viz",
"tools/third-party-licenses",
"tools/editor-message-tree",

2
frontend/.gitignore vendored
View File

@@ -1,4 +1,4 @@
node_modules/
wasm/pkg/
wrapper/pkg/
public/build/
dist/

View File

@@ -12,7 +12,7 @@ Source code for the web app in the form of Svelte components and [TypeScript](ht
## Editor wrapper: `wrapper/`
Wraps the editor backend codebase (`/editor`) and provides a JS-centric API for the web app to use as an entry point, unburdened by Rust's complex data types that are incompatible with JS data types. Bindings (JS functions that call into the Wasm module) are provided by [wasm-bindgen](https://rustwasm.github.io/docs/wasm-bindgen/) in concert with [wasm-pack](https://github.com/rustwasm/wasm-pack).
Wraps the editor backend codebase (`/editor`) and provides a JS-centric API for the web app to use as an entry point, unburdened by Rust's complex data types that are incompatible with JS data types. Bindings (JS functions that call into the Wasm module) are provided by `wasm-bindgen`. As part of `cargo run`, our build tool compiles this crate to Wasm, runs the `wasm-bindgen` CLI to generate the JS/TS bindings in `wrapper/pkg/`, and (for release builds) optimizes the binary with Binaryen's `wasm-opt`.
## ESLint configuration: `eslint.config.js`

View File

@@ -1,4 +1,4 @@
// This script automatically installs the npm packages listed in package-lock.json and runs before `npm start`.
// This script automatically installs the npm packages listed in package-lock.json and runs as part of `npm run setup` (invoked by `cargo run`).
// It skips the installation if this has already run and neither package.json nor package-lock.json has been modified since.
import { execSync } from "child_process";
@@ -11,7 +11,7 @@ const isInstallNeeded = () => {
if (!existsSync(INSTALL_TIMESTAMP_FILE)) return true;
const timestamp = statSync(INSTALL_TIMESTAMP_FILE).mtime;
return ["package.json", "package-lock.json"].some((file) => {
return ["package.json", "package-lock.json", "package-installer.js"].some((file) => {
return existsSync(file) && statSync(file).mtime > timestamp;
});
};
@@ -22,8 +22,10 @@ if (isInstallNeeded()) {
// eslint-disable-next-line no-console
console.log("Installing npm packages...");
// Check if packages are up to date, doing so quickly by using `npm ci`, preferring local cached packages, and skipping the package audit and other checks
execSync("npm ci --prefer-offline --no-audit --no-fund", { stdio: "inherit" });
// Check if packages are up to date, doing so quickly by using `npm ci`, preferring local cached packages, and skipping the package audit and other checks.
// The devDependencies are explicitly included because they hold the build tooling (Vite, etc.), which npm would
// otherwise omit in environments that set NODE_ENV=production (like CI does for the sake of the Vite build).
execSync("npm ci --include=dev --prefer-offline --no-audit --no-fund", { stdio: "inherit" });
// Touch the install timestamp file
writeFileSync(INSTALL_TIMESTAMP_FILE, "");

View File

@@ -14,7 +14,6 @@
"@eslint/js": "^9.39.2",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/node": "^25.5.2",
"concurrently": "^9.2.1",
"eslint": "^9.39.2",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
@@ -2069,84 +2068,6 @@
"node": ">=18"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/cliui/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2184,31 +2105,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2570,16 +2466,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -3206,16 +3092,6 @@
"node": ">= 0.4"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -5270,16 +5146,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "2.0.0-next.6",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
@@ -5358,16 +5224,6 @@
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@@ -5539,19 +5395,6 @@
"node": ">=8"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -5951,22 +5794,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -6126,16 +5953,6 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/treeify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/treeify/-/treeify-1.1.0.tgz",
@@ -6177,7 +5994,8 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
"license": "0BSD",
"optional": true
},
"node_modules/type-check": {
"version": "0.4.0",
@@ -6705,16 +6523,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
@@ -6725,80 +6533,6 @@
"node": ">=18"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/yargs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -6,25 +6,9 @@
"browserslist": "> 1.5%, last 2 versions, not dead, not ie 11, not op_mini all, not ios_saf < 13",
"type": "module",
"scripts": {
"---------- DEV SERVER ----------": "",
"start": "npm run setup && npm run wasm:build-dev && concurrently -k -n \"VITE,RUST\" \"vite\" \"npm run wasm:watch-dev\"",
"production": "npm run setup && npm run wasm:build-production && concurrently -k -n \"VITE,RUST\" \"vite\" \"npm run wasm:watch-production\"",
"---------- BUILDS ----------": "",
"build": "npm run setup && npm run wasm:build-production && vite build",
"build-dev": "npm run setup && npm run wasm:build-dev && vite build --mode dev",
"build-native": "npm run setup && npm run native:build-production",
"build-native-dev": "npm run setup && npm run native:build-dev",
"---------- UTILITIES ----------": "",
"check": "svelte-check --fail-on-warnings && eslint",
"fix": "eslint --fix",
"---------- INTERNAL ----------": "",
"setup": "node package-installer.js && node branding-installer.js",
"native:build-dev": "wasm-pack build ./wrapper --dev --target=web --no-default-features --features native && vite build --mode native",
"native:build-production": "wasm-pack build ./wrapper --release --target=web --no-default-features --features native && vite build --mode native",
"wasm:build-dev": "wasm-pack build ./wrapper --dev --target=web",
"wasm:build-production": "wasm-pack build ./wrapper --release --target=web",
"wasm:watch-dev": "cargo watch --postpone --watch-when-idle --workdir=wrapper --shell \"wasm-pack build . --dev --target=web -- --color=always\"",
"wasm:watch-production": "cargo watch --postpone --watch-when-idle --workdir=wrapper --shell \"wasm-pack build . --release --target=web -- --color=always\""
"setup": "node package-installer.js && node branding-installer.js"
},
"//": "NOTE: `source-sans-pro` is never to be upgraded to 3.x because that renders 1px above its intended position.",
"///": "Waiting on <https://github.com/import-js/eslint-plugin-import/issues/3227> before we can update @eslint/js and eslint to 10.x",
@@ -36,7 +20,6 @@
"@eslint/js": "^9.39.2",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/node": "^25.5.2",
"concurrently": "^9.2.1",
"eslint": "^9.39.2",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",

View File

@@ -40,22 +40,6 @@ ron = { workspace = true }
serde_json = { workspace = true }
node-macro = { workspace = true }
[package.metadata.wasm-pack.profile.dev]
wasm-opt = false
[package.metadata.wasm-pack.profile.dev.wasm-bindgen]
debug-js-glue = true
demangle-name-section = true
dwarf-debug-info = false
[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-Os", "-g"]
[package.metadata.wasm-pack.profile.release.wasm-bindgen]
debug-js-glue = false
demangle-name-section = false
dwarf-debug-info = false
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = [
'cfg(wasm_bindgen_unstable_test_coverage)',

View File

@@ -9,3 +9,5 @@ default-run = "cargo-run"
[dependencies]
thiserror = { workspace = true }
semver = "1"
duct = "1"

View File

@@ -0,0 +1,11 @@
[package]
name = "cargo-run-internal-watch"
edition.workspace = true
version.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
cargo-run = { path = "../.." }
notify = "8.2.0"
notify-debouncer-full = "0.7.0"

View File

@@ -0,0 +1,93 @@
use cargo_run::Error;
use cargo_run::cmd::prelude::*;
use notify::RecursiveMode;
use notify::event::{EventKind, ModifyKind};
use notify_debouncer_full::{Debouncer, RecommendedCache, new_debouncer, notify::RecommendedWatcher};
use std::collections::HashSet;
use std::path::Path;
use std::process::ExitCode;
use std::time::Duration;
const EXCLUDED_DIRECTORIES: &[&str] = &["target", ".git", "frontend/node_modules", "frontend/dist", "frontend/wrapper/pkg"];
const INCLUDED_EXTENSIONS: &[&str] = &["rs"];
const DEBOUNCE: Duration = Duration::from_millis(500);
fn main() -> ExitCode {
let release = std::env::args().nth(1).as_deref() == Some("release");
let _guard = match watch(cargo_run::frontend::build_wasm_steps(release, false)) {
Ok(guard) => guard,
Err(e) => {
eprintln!("Error setting up file watcher: {e}");
return ExitCode::FAILURE;
}
};
println!("Watching for changes...");
loop {
std::thread::park();
}
}
pub struct WatchGuard {
_debouncer: Debouncer<RecommendedWatcher, RecommendedCache>,
}
pub fn watch(steps: impl IntoIterator<Item = Expression>) -> Result<WatchGuard, Error> {
let steps: Vec<Expression> = steps.into_iter().collect();
let root = std::env::current_dir()
.and_then(|p| p.canonicalize())
.map_err(|e| Error::Io(e, "Failed to resolve root for file watcher".into()))?;
let root_clone = root.clone();
let mut current: Option<Sequence> = None;
let mut debouncer = new_debouncer(DEBOUNCE, None, move |result: notify_debouncer_full::DebounceEventResult| match result {
Ok(events) => {
let mut seen = HashSet::new();
let mut triggered = false;
for ev in events {
if !matches!(
&ev.event.kind,
EventKind::Create(_) | EventKind::Remove(_) | EventKind::Modify(ModifyKind::Data(_) | ModifyKind::Name(_) | ModifyKind::Any)
) {
continue;
}
for path in &ev.event.paths {
if is_excluded(path, &root) {
continue;
}
if seen.insert(path.clone()) {
triggered = true;
}
}
}
if triggered {
if let Some(c) = current.take() {
c.kill();
}
current = Some(sequence(steps.clone()));
}
}
Err(errors) => {
for e in errors {
eprintln!("watch: {e}");
}
}
})
.map_err(|e| Error::Io(std::io::Error::other(e.to_string()), "file watcher".into()))?;
debouncer
.watch(&root_clone, RecursiveMode::Recursive)
.map_err(|e| Error::Io(std::io::Error::other(e.to_string()), "file watcher".into()))?;
Ok(WatchGuard { _debouncer: debouncer })
}
fn is_excluded(path: &Path, root: &Path) -> bool {
let rel = path.strip_prefix(root).unwrap_or(path);
if EXCLUDED_DIRECTORIES.iter().any(|d| rel.starts_with(d)) {
return true;
}
!path.extension().and_then(|e| e.to_str()).is_some_and(|e| INCLUDED_EXTENSIONS.contains(&e))
}

334
tools/cargo-run/src/cmd.rs Normal file
View File

@@ -0,0 +1,334 @@
use crate::Error;
use duct::{Handle, ReaderHandle};
use std::ffi::OsString;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
pub use duct::{Expression, cmd};
pub mod prelude {
pub use super::{Expression, ExpressionExt, Sequence, TerminalColor, cmd, sequence, supervise, utils};
}
pub trait ExpressionExt {
fn arg(self, arg: impl Into<OsString>) -> Self;
fn args<I, S>(self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>;
fn arg_if(self, cond: bool, arg: impl Into<OsString>) -> Self;
fn args_if<I, S>(self, cond: bool, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>;
fn run(self) -> Result<(), Error>;
fn read(self) -> Result<String, Error>;
fn output_unchecked(self) -> Result<std::process::Output, Error>;
}
impl ExpressionExt for Expression {
fn arg(self, arg: impl Into<OsString>) -> Self {
let arg = arg.into();
self.before_spawn(move |c| {
c.arg(&arg);
Ok(())
})
}
fn args<I, S>(self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
self.before_spawn(move |c| {
c.args(&args);
Ok(())
})
}
fn arg_if(self, cond: bool, arg: impl Into<OsString>) -> Self {
if cond { self.arg(arg) } else { self }
}
fn args_if<I, S>(self, cond: bool, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
if cond { self.args(args) } else { self }
}
fn run(self) -> Result<(), Error> {
Expression::run(&self).map_err(Error::Command)?;
Ok(())
}
fn read(self) -> Result<String, Error> {
Expression::read(&self).map_err(Error::Command)
}
fn output_unchecked(self) -> Result<std::process::Output, Error> {
let e = self.unchecked().stdout_capture().stderr_capture();
Expression::run(&e).map_err(Error::Command)
}
}
pub enum TerminalColor {
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
White,
Reset,
}
impl TerminalColor {
fn as_str(&self) -> &'static str {
match self {
Self::Black => "\x1b[30m",
Self::Red => "\x1b[31m",
Self::Green => "\x1b[32m",
Self::Yellow => "\x1b[33m",
Self::Blue => "\x1b[34m",
Self::Magenta => "\x1b[35m",
Self::Cyan => "\x1b[36m",
Self::White => "\x1b[37m",
Self::Reset => "\x1b[0m",
}
}
}
pub fn sequence<I: IntoIterator<Item = Expression>>(expressions: I) -> Sequence {
let expressions: Vec<Expression> = expressions.into_iter().collect();
let current: Arc<Mutex<Option<Arc<Handle>>>> = Arc::new(Mutex::new(None));
let killed = Arc::new(AtomicBool::new(false));
let worker_current = Arc::clone(&current);
let worker_killed = Arc::clone(&killed);
let worker = std::thread::spawn(move || {
for expr in expressions {
if worker_killed.load(Ordering::SeqCst) {
return;
}
let handle = match expr.start() {
Ok(h) => Arc::new(h),
Err(e) => {
eprintln!("sequence: failed to start step: {e}");
return;
}
};
{
let mut slot = worker_current.lock().unwrap();
if worker_killed.load(Ordering::SeqCst) {
let _ = handle.kill();
return;
}
*slot = Some(Arc::clone(&handle));
}
let result = handle.wait().map(|_| ());
worker_current.lock().unwrap().take();
if worker_killed.load(Ordering::SeqCst) {
return;
}
if let Err(e) = result {
eprintln!("sequence: step failed: {e}");
return;
}
}
});
Sequence {
current,
killed,
worker: Some(worker),
}
}
pub struct Sequence {
current: Arc<Mutex<Option<Arc<Handle>>>>,
killed: Arc<AtomicBool>,
worker: Option<JoinHandle<()>>,
}
impl Sequence {
pub fn kill(&self) {
let slot = self.current.lock().unwrap();
self.killed.store(true, Ordering::SeqCst);
if let Some(handle) = slot.as_ref() {
let _ = handle.kill();
}
}
pub fn wait(&mut self) {
if let Some(w) = self.worker.take() {
let _ = w.join();
}
}
}
impl Drop for Sequence {
fn drop(&mut self) {
self.kill();
self.wait();
}
}
pub fn supervise<I, S>(children: I) -> Result<(), Error>
where
I: IntoIterator<Item = (S, TerminalColor, Expression)>,
S: Into<String>,
{
use std::io::{BufRead, BufReader, IsTerminal, Write};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
#[cfg(target_os = "windows")]
windows_ctrl_c::install();
let mut handles: Vec<(String, TerminalColor, Arc<ReaderHandle>)> = Vec::new();
for (label, color, expr) in children {
#[cfg(target_os = "windows")]
let expr = expr.before_spawn(|cmd| {
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
Ok(())
});
let handle = expr.stderr_to_stdout().reader().map_err(Error::Command)?;
handles.push((label.into(), color, Arc::new(handle)));
}
let mut io_threads = Vec::new();
for (label, color, handle) in handles.iter() {
let prefix = if std::io::stdout().is_terminal() {
format!("{color}[{label}]{reset} ", color = color.as_str(), reset = TerminalColor::Reset.as_str())
} else {
format!("[{label}] ")
};
let handle = handle.clone();
io_threads.push(thread::spawn(move || {
let reader = BufReader::new(&*handle);
for line in reader.lines().map_while(Result::ok) {
let out = std::io::stdout();
let mut out = out.lock();
let _ = writeln!(out, "{prefix}{line}");
}
}));
}
let mut reason: Option<(String, std::process::ExitStatus)> = None;
loop {
#[cfg(target_os = "windows")]
if windows_ctrl_c::interrupted() {
break;
}
for (label, _color, handle) in handles.iter() {
if let Ok(Some(output)) = handle.try_wait() {
reason = Some((label.clone(), output.status));
break;
}
}
if reason.is_some() {
break;
}
thread::sleep(Duration::from_millis(200));
}
for (_, _color, handle) in handles.iter() {
if matches!(handle.try_wait(), Ok(Some(_))) {
continue;
}
#[cfg(target_os = "windows")]
{
for pid in handle.pids() {
let _ = std::process::Command::new("taskkill")
.args(["/T", "/F", "/PID", &pid.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
#[cfg(not(target_os = "windows"))]
{
let _ = handle.kill();
}
}
for t in io_threads {
let _ = t.join();
}
match reason {
Some((label, status)) if !status.success() => Err(Error::Command(std::io::Error::other(format!("supervised child '{label}' exited with status {status}")))),
_ => Ok(()),
}
}
pub mod utils {
use super::*;
pub fn internal(name: &str) -> Expression {
let package = format!("cargo-run-internal-{name}");
cmd!("cargo", "run", "-p", package, "--")
}
pub fn npm<I, S>(args: I) -> Expression
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
let prog = if cfg!(target_os = "windows") { "npm.cmd" } else { "npm" };
cmd(prog, args)
}
pub fn node_bin(rel: &str) -> Expression {
cmd!("node", format!("node_modules/{rel}"))
}
pub fn open_url(url: &str) -> Result<(), Error> {
#[cfg(target_os = "windows")]
let expr = cmd!("cmd", "/c", "start", url);
#[cfg(target_os = "macos")]
let expr = cmd!("open", url);
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
let expr = cmd!("xdg-open", url);
expr.run()
}
}
#[cfg(target_os = "windows")]
mod windows_ctrl_c {
use std::sync::Once;
use std::sync::atomic::{AtomicBool, Ordering};
static INTERRUPTED: AtomicBool = AtomicBool::new(false);
#[link(name = "kernel32")]
unsafe extern "system" {
fn SetConsoleCtrlHandler(handler: Option<unsafe extern "system" fn(u32) -> i32>, add: i32) -> i32;
}
unsafe extern "system" fn handler(_ctrl_type: u32) -> i32 {
INTERRUPTED.store(true, Ordering::SeqCst);
1 // Report the event as handled
}
pub fn install() {
INTERRUPTED.store(false, Ordering::SeqCst);
static REGISTER: Once = Once::new();
REGISTER.call_once(|| unsafe {
SetConsoleCtrlHandler(Some(handler), 1);
});
}
pub fn interrupted() -> bool {
INTERRUPTED.load(Ordering::SeqCst)
}
}

View File

@@ -0,0 +1,62 @@
use crate::cmd::prelude::*;
use crate::*;
use std::path::PathBuf;
const WRAPPER_CRATE: &str = "graphite-wasm-wrapper";
const WASM_TARGET: &str = "wasm32-unknown-unknown";
const OUT_NAME: &str = "graphite_wasm_wrapper";
pub fn frontend_dir() -> PathBuf {
workspace_dir().join("frontend")
}
pub fn setup() -> Result<(), Error> {
utils::npm(["run", "setup"]).dir(frontend_dir()).run()
}
pub fn build_wasm(release: bool, native: bool) -> Result<(), Error> {
sequence(build_wasm_steps(release, native)).wait();
Ok(())
}
pub fn build_wasm_steps(release: bool, native: bool) -> Vec<Expression> {
let wasm_artifact = target_dir().join(WASM_TARGET).join(if release { "release" } else { "debug" }).join(format!("{OUT_NAME}.wasm"));
let pkg_dir = frontend_dir().join("wrapper").join("pkg");
let mut steps = vec![
cmd!("cargo", "build", "--lib", "--package", WRAPPER_CRATE, "--target", WASM_TARGET)
.arg_if(release, "--release")
.args_if(native, ["--no-default-features", "--features", "native"])
.dir(workspace_dir()),
cmd!("wasm-bindgen", "--target", "web", "--out-name", OUT_NAME, "--out-dir", &pkg_dir, &wasm_artifact)
.arg_if(release, "--no-demangle")
.arg_if(!release, "--debug"),
];
if release {
let wasm_file = pkg_dir.join(format!("{OUT_NAME}_bg.wasm"));
steps.push(cmd!("wasm-opt", "-Os", "-g", &wasm_file, "-o", &wasm_file));
}
steps
}
pub fn vite() -> Expression {
utils::node_bin("vite/bin/vite.js").dir(frontend_dir()).env("CARGO_TARGET_DIR", target_dir())
}
pub fn watch(release: bool) -> Result<(), Error> {
use crate::cmd::prelude::*;
setup()?;
build_wasm(release, false)?;
let vite = vite().env("FORCE_COLOR", "1").env("CARGO_TERM_COLOR", "always");
let rust = utils::internal("watch")
.arg_if(release, "release")
.dir(workspace_dir())
.env("CARGO_TARGET_DIR", target_dir())
.env("CARGO_TERM_COLOR", "always");
supervise([("VITE", TerminalColor::Magenta, vite), ("RUST", TerminalColor::Blue, rust)])
}

View File

@@ -1,6 +1,7 @@
use std::path::PathBuf;
use std::process;
pub mod cmd;
pub mod frontend;
pub mod requirements;
pub enum Action {
@@ -65,70 +66,25 @@ impl Task {
}
}
pub fn run(command: &str) -> Result<(), Error> {
run_from(command, None)
pub fn workspace_dir() -> PathBuf {
PathBuf::from(env!("CARGO_WORKSPACE_DIR"))
}
pub fn npm_run_in_frontend_dir(args: &str) -> Result<(), Error> {
let workspace_dir = std::path::PathBuf::from(env!("CARGO_WORKSPACE_DIR"));
let frontend_dir = workspace_dir.join("frontend");
let npm = if cfg!(target_os = "windows") { "npm.cmd" } else { "npm" };
run_from(&format!("{npm} run {args}"), Some(&frontend_dir))
}
pub fn open_url(url: &str) -> Result<(), Error> {
#[cfg(target_os = "windows")]
let mut cmd = process::Command::new("cmd");
#[cfg(target_os = "windows")]
cmd.args(["/c", "start", url]);
#[cfg(target_os = "macos")]
let mut cmd = process::Command::new("open");
#[cfg(target_os = "macos")]
cmd.arg(url);
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
let mut cmd = process::Command::new("xdg-open");
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
cmd.arg(url);
let command_str = format!("{:?}", cmd);
let exit_code = cmd
.spawn()
.map_err(|e| Error::Io(e, format!("Failed to spawn command '{command_str}'")))?
.wait()
.map_err(|e| Error::Io(e, format!("Failed to wait for command '{command_str}'")))?;
if !exit_code.success() {
return Err(Error::Command(command_str, exit_code));
pub fn target_dir() -> PathBuf {
match std::env::var_os("CARGO_TARGET_DIR") {
Some(custom_dir) => workspace_dir().join(custom_dir),
None => workspace_dir().join("target"),
}
Ok(())
}
fn run_from(command: &str, dir: Option<&PathBuf>) -> Result<(), Error> {
let command = command.split_whitespace().collect::<Vec<_>>();
let mut cmd = process::Command::new(command[0]);
if command.len() > 1 {
cmd.args(&command[1..]);
}
if let Some(dir) = dir {
cmd.current_dir(dir);
}
let exit_code = cmd
.spawn()
.map_err(|e| Error::Io(e, format!("Failed to spawn command '{}'", command.join(" "))))?
.wait()
.map_err(|e| Error::Io(e, format!("Failed to wait for command '{}'", command.join(" "))))?;
if !exit_code.success() {
return Err(Error::Command(command.join(" "), exit_code));
}
Ok(())
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("One or more requirements were not met")]
RequirementsNotMet,
#[error("{1}: {0}")]
Io(#[source] std::io::Error, String),
#[error("Command '{0}' exited with code {1}")]
Command(String, process::ExitStatus),
/// Used by the duct-based `cmd` module; folds in `Command` once call sites are migrated.
#[error("{0}")]
Command(#[source] std::io::Error),
}

View File

@@ -1,5 +1,6 @@
use std::process::ExitCode;
use cargo_run::cmd::prelude::*;
use cargo_run::*;
fn usage() {
@@ -33,6 +34,8 @@ fn usage() {
}
fn main() -> ExitCode {
prepend_path();
let args: Vec<String> = std::env::args().collect();
let args: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
@@ -67,9 +70,9 @@ fn explore_usage() {
fn run_task(task: &Task) -> Result<(), Error> {
if let Action::Explore(tool) = &task.action {
match tool.as_deref() {
Some("bisect") => return open_url("https://graphite.art/volunteer/guide/codebase-overview/debugging-tips/#build-bisect-tool"),
Some("deps") => return open_url("https://graphite.art/volunteer/guide/codebase-overview/#crate-dependency-graph"),
Some("editor") => return open_url("https://graphite.art/volunteer/guide/codebase-overview/editor-structure/#editor-outline"),
Some("bisect") => return utils::open_url("https://graphite.art/volunteer/guide/codebase-overview/debugging-tips/#build-bisect-tool"),
Some("deps") => return utils::open_url("https://graphite.art/volunteer/guide/codebase-overview/#crate-dependency-graph"),
Some("editor") => return utils::open_url("https://graphite.art/volunteer/guide/codebase-overview/editor-structure/#editor-outline"),
None | Some("--help") => {
explore_usage();
return Ok(());
@@ -85,11 +88,19 @@ fn run_task(task: &Task) -> Result<(), Error> {
requirements::check(task)?;
match (&task.action, &task.target, &task.profile) {
(Action::Run, Target::Web, Profile::Debug | Profile::Default) => npm_run_in_frontend_dir("start")?,
(Action::Run, Target::Web, Profile::Release) => npm_run_in_frontend_dir("production")?,
(Action::Run, Target::Web, Profile::Debug | Profile::Default) => frontend::watch(false)?,
(Action::Run, Target::Web, Profile::Release) => frontend::watch(true)?,
(Action::Build, Target::Web, Profile::Debug) => npm_run_in_frontend_dir("build-dev")?,
(Action::Build, Target::Web, Profile::Release | Profile::Default) => npm_run_in_frontend_dir("build")?,
(Action::Build, Target::Web, Profile::Debug) => {
frontend::setup()?;
frontend::build_wasm(false, false)?;
frontend::vite().args(["build", "--mode", "dev"]).run()?;
}
(Action::Build, Target::Web, Profile::Release | Profile::Default) => {
frontend::setup()?;
frontend::build_wasm(true, false)?;
frontend::vite().args(["build"]).run()?;
}
(action, Target::Desktop, mut profile) => {
if matches!(profile, Profile::Default) {
@@ -100,34 +111,41 @@ fn run_task(task: &Task) -> Result<(), Error> {
}
}
if matches!(profile, Profile::Release) {
npm_run_in_frontend_dir("build-native")?;
} else {
npm_run_in_frontend_dir("build-native-dev")?;
};
// Build the editor's Wasm module with the `native` feature, then bundle the frontend with Vite
frontend::setup()?;
frontend::build_wasm(matches!(profile, Profile::Release), true)?;
frontend::vite().args(["build", "--mode", "native"]).run()?;
run("cargo run -p third-party-licenses --features desktop")?;
cmd!("cargo", "run", "-p", "third-party-licenses", "--features", "desktop").run()?;
let cargo_profile = match profile {
Profile::Debug => "dev",
Profile::Release => "release",
Profile::Default => unreachable!(),
};
let args = if matches!(action, Action::Run) {
format!(" -- open {}", task.args.join(" "))
} else {
"".to_string()
};
run(&format!("cargo run --profile {cargo_profile} -p graphite-desktop-bundle{args}"))?;
cmd!("cargo", "run", "--profile", cargo_profile, "-p", "graphite-desktop-bundle")
.args_if(matches!(action, Action::Run), ["--", "open"].into_iter().chain(task.args.iter().map(String::as_str)))
.run()?;
}
(Action::Run, Target::Cli, Profile::Debug | Profile::Default) => run(&format!("cargo run -p graphene-cli -- {}", task.args.join(" ")))?,
(Action::Run, Target::Cli, Profile::Release) => run(&format!("cargo run -r -p graphene-cli -- {}", task.args.join(" ")))?,
(Action::Run, Target::Cli, Profile::Debug | Profile::Default) => cmd!("cargo", "run", "-p", "graphene-cli", "--").args(&task.args).run()?,
(Action::Run, Target::Cli, Profile::Release) => cmd!("cargo", "run", "-r", "-p", "graphene-cli", "--").args(&task.args).run()?,
(Action::Build, Target::Cli, Profile::Debug) => run("cargo build -p graphene-cli")?,
(Action::Build, Target::Cli, Profile::Release | Profile::Default) => run("cargo build -r -p graphene-cli")?,
(Action::Build, Target::Cli, Profile::Debug) => cmd!("cargo", "build", "-p", "graphene-cli").run()?,
(Action::Build, Target::Cli, Profile::Release | Profile::Default) => cmd!("cargo", "build", "-r", "-p", "graphene-cli").run()?,
(Action::Explore(_), _, _) => unreachable!(),
}
Ok(())
}
fn prepend_path() {
let mut paths = vec![target_dir().join("cargo-run").join("bin")];
if let Some(path) = std::env::var_os("PATH") {
paths.extend(std::env::split_paths(&path));
}
if let Ok(joined) = std::env::join_paths(paths) {
// Safety: this runs before any other threads are spawned
unsafe { std::env::set_var("PATH", joined) };
}
}

View File

@@ -1,15 +1,17 @@
use semver::{Version, VersionReq};
use std::io::IsTerminal;
use std::process::Command;
use crate::cmd::prelude::*;
use crate::*;
#[derive(Default, Clone)]
struct Requirement {
pub struct Requirement {
command: &'static str,
args: &'static [&'static str],
name: &'static str,
check: Check,
version: Option<&'static str>,
install: Option<&'static str>,
install: InstallAction,
skip: Option<&'static dyn Fn(&Task) -> bool>,
}
@@ -22,43 +24,43 @@ fn requirements(task: &Task) -> Vec<Requirement> {
..Default::default()
},
Requirement {
command: "cargo-about",
args: &["--version"],
name: "cargo-about",
install: Some("cargo install cargo-about"),
command: "rustc",
args: &["--print", "target-libdir", "--target", "wasm32-unknown-unknown"],
check: Check::Matches(&|out| std::path::Path::new(out.trim()).is_dir()),
name: "Rust Wasm Toolchain",
install: "rustup target add wasm32-unknown-unknown".into(),
skip: Some(&|task| matches!(task.target, Target::Cli)),
..Default::default()
},
Requirement {
command: "cargo-watch",
command: "cargo-about",
args: &["--version"],
name: "cargo-watch",
install: Some("cargo install cargo-watch"),
name: "Cargo About",
install: "cargo install cargo-about".into(),
skip: Some(&|task| matches!(task.target, Target::Cli)),
..Default::default()
},
Requirement {
command: "wasm-opt",
args: &["--version"],
name: "Wasm Opt",
version: Some(">=130"),
skip: Some(&|task| {
!matches!(
task,
Task {
target: Target::Web,
action: Action::Run,
..
matches!(task.target, Target::Cli)
|| match task.profile {
Profile::Debug => true,
Profile::Release => false,
Profile::Default => matches!(task.action, Action::Run),
}
)
}),
..Default::default()
},
Requirement {
command: "wasm-bindgen",
args: &["--version"],
name: "wasm-bindgen-cli",
version: Some("0.2.121"),
install: Some("cargo install -f wasm-bindgen-cli@0.2.121"),
skip: Some(&|task| matches!(task.target, Target::Cli)),
},
Requirement {
command: "wasm-pack",
args: &["--version"],
name: "wasm-pack",
install: Some("cargo install wasm-pack"),
name: "Wasm Bindgen",
version: Some("=0.2.121"),
install: "cargo install -f wasm-bindgen-cli@0.2.121".into(),
skip: Some(&|task| matches!(task.target, Target::Cli)),
..Default::default()
},
@@ -98,24 +100,44 @@ pub fn check(task: &Task) -> Result<(), Error> {
let mut failures: Vec<String> = Vec::new();
for dep in requirements(task) {
match Command::new(dep.command).args(dep.args).output() {
match cmd(dep.command, dep.args.iter().copied()).output_unchecked() {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout);
let version = version.lines().next().unwrap_or_default().trim();
if let Some(expected) = dep.version {
if version.contains(expected) {
eprintln!("{} ({})", dep.name, version);
} else {
eprintln!("{} (found {}, expected {})", dep.name, version, expected);
if dep.install.is_some() {
installable.push(dep);
} else {
failures.push(format!("{}: version mismatch (found {version}, expected {expected})", dep.name));
let stdout = String::from_utf8_lossy(&output.stdout);
match dep.check {
Check::PrintsVersion => {
let line = stdout.lines().next().unwrap_or_default().trim();
match dep.version {
None => eprintln!("{} ({line})", dep.name),
Some(req_str) => {
let req = VersionReq::parse(req_str).expect("invalid semver requirement");
match extract_version(line) {
Some(version) if req.matches(&version) => eprintln!("{} ({version})", dep.name),
Some(version) => {
eprintln!("{} (found {version}, requires {req_str})", dep.name);
if dep.install.is_some() {
installable.push(dep);
} else {
failures.push(format!("{}: version mismatch (found {version}, requires {req_str})", dep.name));
}
}
None => {
eprintln!("{} (could not parse version from '{line}')", dep.name);
failures.push(format!("{}: could not parse version from '{line}'", dep.name));
}
}
}
}
}
Check::Matches(check) => {
if !check(stdout.to_string()) {
eprintln!("{} - check failed", dep.name);
if dep.install.is_some() {
installable.push(dep);
}
} else {
eprintln!("{}", dep.name);
}
}
} else {
eprintln!("{} ({})", dep.name, version);
}
}
Ok(output) => {
@@ -147,7 +169,7 @@ pub fn check(task: &Task) -> Result<(), Error> {
let total = installable.len() + failures.len();
eprintln!("{total} requirement{} not met:", if total > 1 { "s" } else { "" });
for dep in &installable {
eprintln!(" - {}: {}", dep.name, dep.install.unwrap());
eprintln!(" - {}", dep.name);
}
for msg in &failures {
eprintln!(" - {msg}");
@@ -158,43 +180,149 @@ pub fn check(task: &Task) -> Result<(), Error> {
eprintln!("See: https://graphite.art/volunteer/guide/project-setup/");
}
let is_interactive = std::io::stdout().is_terminal() && std::io::stderr().is_terminal() && std::io::stdin().is_terminal();
// Don't prompt for automatic installation if we're not interactive session
if !std::io::stdout().is_terminal() || !std::io::stderr().is_terminal() || !std::io::stdin().is_terminal() {
if !is_interactive {
return Ok(());
}
if installable.is_empty() {
if installable.is_empty() && failures.is_empty() {
return Ok(());
}
eprintln!();
eprintln!("The following can be installed automatically:");
for dep in &installable {
eprintln!(" {}", dep.install.unwrap());
}
eprintln!();
if installable.len() == 1 {
eprint!("Install it now? [Y/n] ");
} else {
eprint!("Install them now? [Y/n] ");
}
let mut input = String::new();
std::io::stdin().read_line(&mut input).map_err(|e| Error::Io(e, "Failed to read from stdin".into()))?;
let input = input.trim();
if input.is_empty() || input.eq_ignore_ascii_case("y") || input.eq_ignore_ascii_case("yes") {
if !installable.is_empty() {
eprintln!();
eprintln!("The following can be installed automatically:");
for dep in &installable {
let parts: Vec<&str> = dep.install.unwrap().split_whitespace().collect();
eprintln!("Running: {}...", dep.install.unwrap());
let status = Command::new(parts[0])
.args(&parts[1..])
.status()
.map_err(|e| Error::Io(e, format!("Failed to run '{}'", dep.install.unwrap())))?;
if !status.success() {
eprintln!("Failed to install {}", dep.name);
eprintln!(" {}: {}", dep.name, dep.install.description());
}
eprintln!();
if installable.len() == 1 {
eprint!("Install it now? [Y/n] ");
} else {
eprint!("Install them now? [Y/n] ");
}
let mut input = String::new();
std::io::stdin().read_line(&mut input).map_err(|e| Error::Io(e, "Failed to read from stdin".into()))?;
let input = input.trim();
if input.eq_ignore_ascii_case("y") || input.eq_ignore_ascii_case("yes") {
let mut successfully_installed = Vec::new();
for (i, dep) in installable.iter().enumerate() {
eprintln!("Installing {}...", dep.name);
match &dep.install {
InstallAction::Command(install_cmd) => {
let parts: Vec<&str> = install_cmd.split_whitespace().collect();
let expr = cmd(parts[0], parts[1..].iter().copied()).unchecked();
match Expression::run(&expr) {
Ok(output) if output.status.success() => successfully_installed.push(i),
Ok(_) => eprintln!("Failed to install {}", dep.name),
Err(e) => return Err(Error::Command(e)),
}
}
InstallAction::Function { function, .. } => {
if let Err(e) = function() {
eprintln!("{e}");
eprintln!("Failed to install {}", dep.name);
} else {
successfully_installed.push(i);
}
}
InstallAction::None => unreachable!(),
}
}
for i in successfully_installed.into_iter().rev() {
installable.remove(i);
}
}
}
if !failures.is_empty() {
eprintln!();
eprintln!("The following requirements must be resolved manually:");
for msg in &failures {
eprintln!(" - {msg}");
}
}
if (!installable.is_empty() || !failures.is_empty()) && is_interactive {
eprintln!();
eprintln!("Continue without resolving these requirements? [y/N]");
let mut input = String::new();
std::io::stdin().read_line(&mut input).map_err(|e| Error::Io(e, "Failed to read from stdin".into()))?;
let input = input.trim();
if input.eq_ignore_ascii_case("n") || input.eq_ignore_ascii_case("no") {
return Err(Error::RequirementsNotMet);
}
}
Ok(())
}
fn extract_version(line: &str) -> Option<Version> {
line.split_whitespace().find_map(|token| {
let token = token.trim_start_matches('v').trim_end_matches(|c: char| !c.is_ascii_alphanumeric());
if token.is_empty() {
return None;
}
if let Ok(version) = Version::parse(token) {
return Some(version);
}
let (core, suffix) = match token.find(['-', '+']) {
Some(i) => token.split_at(i),
None => (token, ""),
};
let parts: Vec<&str> = core.split('.').collect();
if parts.iter().any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_digit())) {
return None;
}
let major = parts[0];
let minor = parts.get(1).copied().unwrap_or("0");
let patch = parts.get(2).copied().unwrap_or("0");
Version::parse(&format!("{major}.{minor}.{patch}{suffix}")).ok()
})
}
#[derive(Clone, Default)]
enum Check {
#[default]
PrintsVersion,
Matches(&'static dyn Fn(String) -> bool),
}
#[derive(Clone, Default)]
enum InstallAction {
#[default]
None,
Command(&'static str),
#[expect(dead_code)] // TODO: Remove after followup pr landed
Function {
description: &'static str,
function: &'static dyn Fn() -> Result<(), Error>,
},
}
impl InstallAction {
fn is_some(&self) -> bool {
!matches!(self, InstallAction::None)
}
fn description(&self) -> &'static str {
match self {
InstallAction::None => "",
InstallAction::Command(cmd) => cmd,
InstallAction::Function { description, .. } => description,
}
}
}
impl From<&'static str> for InstallAction {
fn from(value: &'static str) -> Self {
InstallAction::Command(value)
}
}

View File

@@ -86,7 +86,7 @@ fn parse(parsed: Output) -> Vec<LicenseEntry> {
fn run() -> Result<Output, Error> {
let output = Command::new("cargo")
.args(["about", "generate", "--format", "json", "--frozen"])
.args(["about", "generate", "--format", "json", "--locked"])
.current_dir(env!("CARGO_WORKSPACE_DIR"))
.output()
.map_err(|e| Error::Io(e, "Failed to run cargo about generate".into()))?;

View File

@@ -98,10 +98,9 @@ fn run() -> Result<(), Error> {
let current_hash = format!("{:016x}", hasher.finish());
if current_hash == fs::read_to_string(&current_hash_path).unwrap_or_default() {
eprintln!("No changes in licenses detected, skipping generation.");
return Ok(());
}
eprintln!("Changes in licenses detected, generating new license file.");
eprintln!("Changes in licenses detected, generating new license file...");
let licenses = merge_filter_dedup_and_sort(vec![
cargo_source.licenses()?,