better dev script

This commit is contained in:
Brandon Thomas 2026-09-26 18:07:10 -07:00
parent df707bf8e1
commit fb458cff2e
6 changed files with 436 additions and 11 deletions

View File

@ -9,21 +9,62 @@ To set up the ArtCraft development environment, install the following:
1. [Install Rust](https://doc.rust-lang.org/cargo/getting-started/installation.html).
2. [Install npm](https://nodejs.org/en/download) or [nvm](https://github.com/nvm-sh/nvm). (Node version `v24.13.0` works at time of writing.)
3. [Install nx](https://nx.dev/docs/getting-started/installation). (Nx version `v22.4.5` works at time of writing.)
3. The frontend's Nx and Vite dependencies are installed locally with npm; a global Nx installation is not required for the combined Unix launcher.
4. [Install Tauri CLI](https://v2.tauri.app/reference/cli/). (Version `tauri-cli 2.10.0` works at time of writing.)
An easy way to get started with running the app in development is to run the two commands (in separate terminals):
**Mac and Linux Development**
```bash
# Run the frontend dev server
./script/artcraft/unix_frontend_dev.sh
# Start the frontend and Rust/Tauri together in one terminal
./script/artcraft/unix_dev.sh
# Run the Tauri Rust application
./script/artcraft/unix_rust_dev.sh
# Optionally start the free-port search at another port
ARTCRAFT_DEV_PORT=6200 ./script/artcraft/unix_dev.sh
```
Use the combined launcher instead of starting the two legacy dev scripts. It
works from any working directory when invoked by its path and installs frontend
dependencies if the local Vite installation is missing. Node.js 20+ is required.
The launcher binds Vite to `127.0.0.1`, starting at port **5193** and trying
successive ports until one is available. It leaves existing listeners alone.
Only after the real frontend socket is bound does it start Tauri with that exact
`devUrl`. HTTP and the HMR websocket share the selected port; Rust communicates
through native Tauri IPC and needs no separate HTTP port. Configuration overrides
are passed in memory, without changing the checked-in Tauri config.
JavaScript, TypeScript, React, and CSS changes use Vite hot reload; compatible
React component edits use Fast Refresh. Changes that cannot be hot-replaced
reload the page. Rust changes use `cargo tauri dev`'s automatic **rebuild and app
restart**, including dependent workspace crates. SQLx metadata and SQLite
migrations are also watched. Rust reload is a process restart, so it does not
preserve unsaved in-memory app state. See [Tauri's watch behavior](https://v2.tauri.app/develop/#reacting-to-source-code-changes).
After startup, a Vite configuration restart must reuse the chosen port or report
an error; it cannot silently switch to another port while Tauri uses an old URL.
Ctrl-C stops this session's frontend, Rust watcher/compiler, and desktop app.
If the Rust watcher exits, the launcher closes Vite and propagates the exit code.
It never finds or terminates another app by name or port.
The launcher retains the existing Unix defaults for `VITE_ENVIRONMENT_TYPE`,
`SQLX_OFFLINE`, `RUSTFLAGS`, and the Linux WebKit workarounds. Explicit environment
overrides are preserved. It uses the normal Artcraft app data and Cargo target
directory; port separation does not create an isolated account/data profile, and
concurrent Rust builds can wait on Cargo's build lock.
To verify the launcher without compiling Rust or opening a desktop window:
```bash
node --test frontend/scripts/unix-dev.test.mjs
```
The tests use real Vite listeners and HMR, with a test-owned fake Cargo process
tree. They cover occupied ports, simultaneous launches, React edits, shutdown,
Rust-process failure, and watcher arguments. To verify native Rust reload, run
the launcher, wait for the desktop window, make a Rust source edit, and check for
a rebuild/restart in the same terminal. Frontend edits should update the webview
without restarting the Rust process.
**Windows Development**
```powershell

View File

@ -1,4 +1,5 @@
# Don't restart the dev server on Rust changes
crates/
Cargo.toml
Cargo.lock
# Rust sources and Cargo manifests must remain watched for `cargo tauri dev`.
# Vite owns frontend reloads; build artifacts must not trigger Rust rebuilds.
target/
node_modules/
dist/

View File

@ -1,4 +1,5 @@
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import path from "path";
import { resolve } from "node:path";
@ -58,6 +59,7 @@ export default defineConfig({
},
},
plugins: [
react(),
sparkWasmDataUrlFix(),
tsconfigPaths({
// Resolve workspace packages to source for every importer, including shared

View File

@ -0,0 +1,149 @@
// Invoked by script/artcraft/unix_dev.sh. Adapted from ArtcraftX's launcher.
// Living under frontend lets Node resolve the local Vite installation.
import { spawn } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { createServer } from "vite";
const rootDir = fileURLToPath(new URL("../../", import.meta.url));
const frontendDir = path.join(rootDir, "frontend");
const appDir = path.join(frontendDir, "apps/artcraft");
const rustDir = path.join(rootDir, "crates/desktop/artcraft");
const host = "127.0.0.1";
const startingPort = Number(process.env.ARTCRAFT_DEV_PORT ?? "5193");
let vite;
let rust;
let stopping = false;
async function main() {
if (!Number.isInteger(startingPort) || startingPort < 1024 || startingPort > 65535) {
throw new Error("ARTCRAFT_DEV_PORT must be an integer between 1024 and 65535.");
}
process.env.VITE_ENVIRONMENT_TYPE ??= "production";
// Match the app build's cwd, including Tailwind config/content resolution.
process.chdir(appDir);
vite = await createServer({
configFile: path.join(appDir, "vite.config.ts"),
clearScreen: false,
server: {
host,
port: startingPort,
// Vite retries occupied ports while binding the real server. There is no
// port probe/release/rebind race, and no existing process is terminated.
strictPort: false,
open: false,
hmr: true,
},
});
if (stopping) return;
await vite.listen();
const { port } = vite.httpServer.address();
const devUrl = `http://${host}:${port}`;
// Only initial startup may choose another port. On a Vite config restart,
// rebind this port or fail visibly so Tauri never follows a silent port hop.
for (const options of [vite.config.server, vite.config.inlineConfig.server]) {
options.port = port;
options.strictPort = true;
}
const configPath = path.join(rustDir, "tauri-dev-hot-reload.conf.json");
const config = JSON.parse(await readFile(configPath, "utf8"));
if (stopping) return;
const csp = config.app.security.csp;
const overrides = {
build: { devUrl, beforeDevCommand: "" },
app: {
security: {
devCsp: {
...csp,
"connect-src": [...csp["connect-src"], devUrl, `ws://${host}:${port}`],
},
},
},
};
console.log(`\n[artcraft] Frontend + HMR: ${devUrl}`);
console.log("[artcraft] Rust/Tauri: native IPC; automatic rebuild and app restart enabled.");
console.log("[artcraft] Press Ctrl-C to stop both.\n");
rust = spawn("cargo", [
"tauri", "dev",
"--no-dev-server",
// Tauri watches the desktop crate and its workspace dependencies itself.
// SQLx's external metadata and migrations also affect the Rust build.
"--additional-watch-folders", path.join(rootDir, ".sqlx"),
"--additional-watch-folders", path.join(rootDir, "_database/sql/artcraft_migrations"),
"--config", configPath,
"--config", JSON.stringify(overrides),
], {
cwd: rootDir,
// All cargo/rustc/app descendants inherit this private Unix process group.
detached: true,
stdio: ["ignore", "inherit", "inherit"],
env: {
...process.env,
TAURI_FRONTEND_PATH: frontendDir,
TAURI_APP_PATH: rustDir,
SQLX_OFFLINE: process.env.SQLX_OFFLINE ?? "true",
RUSTFLAGS: process.env.RUSTFLAGS ?? "-Awarnings",
WEBKIT_DISABLE_DMABUF_RENDERER: process.env.WEBKIT_DISABLE_DMABUF_RENDERER ?? "1",
WEBKIT_DISABLE_COMPOSITING_MODE: process.env.WEBKIT_DISABLE_COMPOSITING_MODE ?? "1",
},
});
rust.on("error", fail);
rust.on("exit", (code, signal) => {
if (!stopping) {
console.log(`[artcraft] Rust/Tauri exited (${signal ?? code}).`);
void stop(code ?? 1);
}
});
}
async function stop(exitCode) {
if (stopping) return;
stopping = true;
console.log("\n[artcraft] Stopping frontend and Rust/Tauri...");
const deadline = setTimeout(() => {
signalRustGroup("SIGKILL");
process.exit(exitCode);
}, 5000);
signalRustGroup("SIGTERM");
await Promise.allSettled([
vite?.close(),
(async () => {
while (signalRustGroup(0)) await delay(50);
})(),
]);
clearTimeout(deadline);
process.exit(exitCode);
}
function signalRustGroup(signal) {
if (!rust?.pid) return false;
try {
process.kill(-rust.pid, signal);
return true;
} catch (error) {
if (error.code !== "ESRCH") throw error;
return false;
}
}
function fail(error) {
console.error(`[artcraft] ${error.stack ?? error}`);
void stop(1);
}
process.on("SIGINT", () => void stop(130));
process.on("SIGTERM", () => void stop(143));
process.on("SIGHUP", () => void stop(129));
process.on("uncaughtException", fail);
process.on("unhandledRejection", fail);
main().catch(fail);

View File

@ -0,0 +1,188 @@
// Integration tests: real Vite sockets + a fake cargo process tree, no GUI/build.
// Run: node --test frontend/scripts/unix-dev.test.mjs
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import os from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { test } from "node:test";
import WebSocket from "ws";
import { fileURLToPath } from "node:url";
const rootDir = fileURLToPath(new URL("../../", import.meta.url));
const launcher = path.join(rootDir, "script/artcraft/unix_dev.sh");
test("skips occupied ports, pairs Tauri with real Vite/HMR, and cleans up on Ctrl-C", { timeout: 40000 }, async (t) => {
const occupied = await occupyPort(t);
const startingPort = occupied.address().port;
const run = await fixture(t, { ARTCRAFT_DEV_PORT: String(startingPort) });
const record = await run.record();
const config = overrides(record);
const url = config.build.devUrl;
assert.ok(Number(new URL(url).port) > startingPort);
assert.equal(new URL(url).hostname, "127.0.0.1");
assert.equal(config.build.beforeDevCommand, "");
assert.ok(!record.args.includes("--no-watch"));
assert.ok(!record.args.includes("--no-dev-server-wait"));
assert.equal(record.env.TAURI_APP_PATH, path.join(rootDir, "crates/desktop/artcraft"));
assert.equal(record.env.TAURI_FRONTEND_PATH, path.join(rootDir, "frontend"));
assert.ok(config.app.security.devCsp["connect-src"].includes(url.replace("http:", "ws:")));
assert.match(await (await fetch(url)).text(), /\/src\/index\.tsx/);
assert.match(await (await fetch(`${url}/@vite/client`)).text(), /WebSocket/);
run.proc.kill("SIGINT");
assert.deepEqual(await run.exited, [130, null], run.output());
await until(() => !alive(record.pid) && !alive(record.childPid));
await assert.rejects(fetch(url));
assert.equal(occupied.listening, true, "must not terminate the existing listener");
});
test("Tauri failure propagates and stops Vite and leftover descendants", { timeout: 40000 }, async (t) => {
const run = await fixture(t, { TEST_EXIT_CODE: "17" });
const record = await run.record();
assert.deepEqual(await run.exited, [17, null], run.output());
await until(() => !alive(record.childPid));
await assert.rejects(fetch(overrides(record).build.devUrl));
});
test("SIGTERM shuts down the complete process tree", { timeout: 40000 }, async (t) => {
const run = await fixture(t);
const record = await run.record();
run.proc.kill("SIGTERM");
assert.deepEqual(await run.exited, [143, null], run.output());
await until(() => !alive(record.pid) && !alive(record.childPid));
await assert.rejects(fetch(overrides(record).build.devUrl));
});
test("invalid ports fail before starting Tauri", { timeout: 40000 }, async (t) => {
const run = await fixture(t, { ARTCRAFT_DEV_PORT: "65536" });
assert.deepEqual(await run.exited, [1, null]);
assert.match(run.output(), /ARTCRAFT_DEV_PORT must be an integer/);
});
test("concurrent launchers use different ports without replacing either frontend", { timeout: 40000 }, async (t) => {
const occupied = await occupyPort(t);
const env = { ARTCRAFT_DEV_PORT: String(occupied.address().port) };
const first = await fixture(t, env);
const second = await fixture(t, env);
const [a, b] = await Promise.all([first.record(), second.record()]);
const firstUrl = overrides(a).build.devUrl;
const secondUrl = overrides(b).build.devUrl;
assert.notEqual(firstUrl, secondUrl);
assert.equal((await fetch(firstUrl)).status, 200);
assert.equal((await fetch(secondUrl)).status, 200);
first.proc.kill("SIGINT");
await first.exited;
assert.equal((await fetch(secondUrl)).status, 200);
assert.ok(alive(b.pid));
});
test("a React source edit sends HMR on the selected port without restarting Rust", { timeout: 40000 }, async (t) => {
const run = await fixture(t);
const record = await run.record();
const url = overrides(record).build.devUrl;
const probeDir = await mkdtemp(path.join(rootDir, "frontend/apps/artcraft/app/src/dev-probe-"));
t.after(() => rm(probeDir, { recursive: true, force: true }));
const probe = path.join(probeDir, "Probe.tsx");
const moduleUrl = `/src/${path.basename(probeDir)}/Probe.tsx`;
await writeFile(probe, "export default function Probe() { return <div>before</div>; }\n");
const code = await (await fetch(`${url}${moduleUrl}`)).text();
assert.match(code, /\$RefreshReg\$/);
const client = await (await fetch(`${url}/@vite/client`)).text();
const token = client.match(/const wsToken = "([^"]+)"/)[1];
const ws = new WebSocket(`${url.replace("http:", "ws:")}/?token=${token}`, "vite-hmr");
t.after(() => ws.terminate());
const messages = [];
ws.on("message", (data) => messages.push(JSON.parse(data.toString())));
await once(ws, "open");
await writeFile(probe, "export default function Probe() { return <div>after</div>; }\n");
await until(() => messages.some((m) => m.type === "update" && m.updates.some((u) => u.path === moduleUrl)));
assert.match(await (await fetch(`${url}${moduleUrl}`)).text(), /after/);
assert.ok(alive(record.pid), "JS edits must leave the Rust watcher running");
assert.equal((await run.record()).pid, record.pid);
});
test("Rust watcher arguments include SQLx inputs and preserve caller overrides", { timeout: 40000 }, async (t) => {
const run = await fixture(t, { SQLX_OFFLINE: "false", RUSTFLAGS: "-C debuginfo=1" });
const record = await run.record();
assert.equal(record.env.SQLX_OFFLINE, "false");
assert.equal(record.env.RUSTFLAGS, "-C debuginfo=1");
assert.ok(record.args.includes(path.join(rootDir, ".sqlx")));
assert.ok(record.args.includes(path.join(rootDir, "_database/sql/artcraft_migrations")));
const ignores = await readFile(path.join(rootDir, "crates/desktop/artcraft/.taurignore"), "utf8");
assert.doesNotMatch(ignores, /^(crates\/|Cargo\.toml|Cargo\.lock)$/m);
});
async function until(check) {
for (let attempt = 0; attempt < 200; attempt++) {
const result = await check();
if (result) return result;
await delay(50);
}
throw new Error("Timed out waiting for dev launcher");
}
async function fixture(t, env = {}) {
const dir = await mkdtemp(path.join(os.tmpdir(), "artcraft-dev-test-"));
t.after(() => rm(dir, { recursive: true, force: true }));
await writeFile(path.join(dir, "cargo"), `#!/usr/bin/env node
const fs = require("node:fs");
const { spawn } = require("node:child_process");
if (process.argv.includes("--version")) process.exit(0);
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
fs.writeFileSync(process.env.TEST_RECORD, JSON.stringify({
args: process.argv.slice(2), cwd: process.cwd(),
env: Object.fromEntries(["TAURI_APP_PATH", "TAURI_FRONTEND_PATH", "SQLX_OFFLINE", "RUSTFLAGS"].map(key => [key, process.env[key]])),
pid: process.pid, childPid: child.pid,
}));
if (process.env.TEST_EXIT_CODE) setTimeout(() => process.exit(Number(process.env.TEST_EXIT_CODE)), 200);
setInterval(() => {}, 1000);
`, { mode: 0o755 });
const recordFile = path.join(dir, "record.json");
const proc = spawn(launcher, [], {
// Prove the launcher is independent of the invoking working directory.
cwd: dir,
env: { ...process.env, ...env, PATH: `${dir}:${process.env.PATH}`, TEST_RECORD: recordFile },
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
proc.stdout.on("data", (chunk) => { output += chunk; });
proc.stderr.on("data", (chunk) => { output += chunk; });
const exited = once(proc, "exit");
t.after(async () => {
if (proc.exitCode === null && proc.signalCode === null) proc.kill("SIGTERM");
await exited;
});
return {
proc, exited, output: () => output,
async record() {
return until(async () => {
if (proc.exitCode !== null) throw new Error(output);
try { return JSON.parse(await readFile(recordFile, "utf8")); }
catch (error) { if (error.code !== "ENOENT") throw error; }
});
},
};
}
async function occupyPort(t) {
const server = createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
t.after(() => new Promise((resolve) => server.close(resolve)));
return server;
}
function alive(pid) {
try { process.kill(pid, 0); return true; }
catch (error) { if (error.code === "ESRCH") return false; throw error; }
}
function overrides(record) {
return JSON.parse(record.args.at(-1));
}

44
script/artcraft/unix_dev.sh Executable file
View File

@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Start the desktop frontend and Rust/Tauri watcher together on macOS or Linux.
set -euo pipefail
root_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
frontend_path="${root_dir}/frontend"
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
cat <<'HELP'
Usage: ./script/artcraft/unix_dev.sh
Starts Vite with React/CSS hot reload and Tauri with Rust rebuild/restart.
Finds and binds a free loopback port starting at ARTCRAFT_DEV_PORT (default: 5193),
then passes that exact URL to Tauri. HMR shares the frontend port.
Rust uses native Tauri IPC and does not need a second HTTP port.
Ctrl-C stops this launcher's frontend, Rust watcher, and desktop app.
Requires Node.js 20+, npm, Rust, cargo-tauri 2, and Tauri platform prerequisites.
Installs frontend dependencies only when the local Vite installation is missing.
Run this command instead of running the two legacy dev scripts together.
HELP
exit 0
fi
if [[ $# -ne 0 ]]; then
echo "Unknown argument: $1 (see --help)" >&2
exit 1
fi
source "${root_dir}/script/common/frontend_preflight.sh"
frontend_preflight "${frontend_path}"
if ! command -v cargo >/dev/null || ! cargo tauri --version >/dev/null 2>&1; then
echo 'ERROR: Install Rust and the Tauri CLI: cargo install tauri-cli --version "^2" --locked' >&2
exit 1
fi
cd "${frontend_path}"
if [[ ! -f node_modules/vite/package.json ]]; then
# Do not use the legacy install helper: its failure recovery deletes caches.
npm install
fi
exec node "${frontend_path}/scripts/unix-dev.mjs"