faster dev script that handles port allocation
This commit is contained in:
parent
4d132a77e0
commit
0f8c9da758
27
README.md
27
README.md
|
|
@ -26,3 +26,30 @@
|
|||
<strong>Early Experimental Release</strong><br>
|
||||
Builds are Forthcoming
|
||||
</p>
|
||||
|
||||
## Development (macOS / Linux)
|
||||
|
||||
With Node.js 20+, npm, Rust, and the Tauri 2 CLI installed, run:
|
||||
|
||||
```bash
|
||||
./script/unix_dev.sh
|
||||
```
|
||||
|
||||
This starts the frontend and desktop app together. It installs frontend
|
||||
dependencies when missing, then binds the first available loopback port starting
|
||||
at **5183** before launching Tauri with that exact URL. Existing processes are
|
||||
left running. To start the port search elsewhere:
|
||||
|
||||
```bash
|
||||
ARTCRAFTX_DEV_PORT=5200 ./script/unix_dev.sh
|
||||
```
|
||||
|
||||
JavaScript, TypeScript, and CSS edits reload through Vite, including shared
|
||||
frontend libraries. Rust edits in the app or its workspace dependencies trigger
|
||||
a rebuild and desktop app restart; in-memory app state resets on a Rust restart.
|
||||
The Rust backend uses native Tauri IPC, so there is no second backend HTTP port
|
||||
to allocate. HTTP and the HMR WebSocket share the selected frontend port.
|
||||
|
||||
Press **Ctrl-C** to stop the frontend, Rust watcher, and app. Use this combined
|
||||
launcher instead of running the two `script/artcraftx/unix_*_dev.sh` scripts.
|
||||
The launcher resolves paths relative to itself and can be called from any directory.
|
||||
|
|
|
|||
|
|
@ -26,22 +26,21 @@ npm install
|
|||
|
||||
## Running the apps in dev mode
|
||||
|
||||
From the **repository root**, the launcher scripts (these preflight your
|
||||
environment, install dependencies, and free the dev port first):
|
||||
From the **repository root**, start the frontend and Tauri desktop app together:
|
||||
|
||||
```bash
|
||||
./script/website/unix_frontend_dev.sh # artcraft-website (marketing site), port 4200
|
||||
./script/website/unix_frontend_webapp_dev.sh # artcraft-webapp (user dashboard), port 4201
|
||||
./script/artcraft/unix_frontend_dev.sh # artcraft (Tauri app frontend), port 5173
|
||||
./script/unix_dev.sh
|
||||
```
|
||||
|
||||
Or directly from this directory:
|
||||
The launcher finds and binds a free port starting at 5183, then gives Tauri the
|
||||
same URL. Set `ARTCRAFTX_DEV_PORT` to change the starting port. Vite handles
|
||||
frontend live reload; Tauri watches Rust workspace dependencies and rebuilds and
|
||||
restarts the app on edits. Ctrl-C stops both. See the repository README for details.
|
||||
|
||||
For just the frontend, directly from this directory:
|
||||
|
||||
```bash
|
||||
nx dev artcraft-website
|
||||
nx dev artcraft-webapp
|
||||
nx dev artcraft # NB: the Tauri app also needs the Rust dev server running
|
||||
nx dev editor2d
|
||||
npx nx dev artcraft
|
||||
```
|
||||
|
||||
## Building
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
// Invoked by script/unix_dev.sh. Keep this under frontend so Node resolves the
|
||||
// workspace's installed Vite, without requiring a global Nx or another install.
|
||||
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/artcraftx");
|
||||
const host = "127.0.0.1";
|
||||
const startingPort = Number(process.env.ARTCRAFTX_DEV_PORT ?? "5183");
|
||||
|
||||
let vite;
|
||||
let rust;
|
||||
let stopping = false;
|
||||
|
||||
// cargo-tauri, cargo, rustc, and the app inherit this dedicated Unix process
|
||||
// group. Never find or kill processes by port/name: ArtCraft may be running too.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function stop(exitCode) {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
console.log("\n[artcraftx] Stopping frontend and Rust/Tauri...");
|
||||
|
||||
// Bound shutdown even when a compiler, webview, or Vite plugin gets stuck.
|
||||
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 fail(error) {
|
||||
console.error(`[artcraftx] ${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);
|
||||
|
||||
async function main() {
|
||||
if (!Number.isInteger(startingPort) || startingPort < 1024 || startingPort > 65535) {
|
||||
throw new Error("ARTCRAFTX_DEV_PORT must be an integer between 1024 and 65535.");
|
||||
}
|
||||
|
||||
process.env.VITE_ENVIRONMENT_TYPE ??= "production";
|
||||
process.chdir(appDir);
|
||||
vite = await createServer({
|
||||
configFile: path.join(appDir, "vite.config.ts"),
|
||||
clearScreen: false,
|
||||
server: {
|
||||
host,
|
||||
port: startingPort,
|
||||
// Vite retries EADDRINUSE on successive ports while actually binding the
|
||||
// server. There is no probe/release/rebind window for a competing app.
|
||||
strictPort: false,
|
||||
open: false,
|
||||
hmr: true,
|
||||
},
|
||||
});
|
||||
if (stopping) return;
|
||||
await vite.listen();
|
||||
const { port } = vite.httpServer.address();
|
||||
const devUrl = `http://${host}:${port}`;
|
||||
|
||||
// On Vite config edits, restart on this same port or fail visibly. Silently
|
||||
// moving again would leave Tauri pointing at the old (possibly foreign) app.
|
||||
for (const serverOptions of [vite.config.server, vite.config.inlineConfig.server]) {
|
||||
serverOptions.port = port;
|
||||
serverOptions.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[artcraftx] Frontend + HMR: ${devUrl}`);
|
||||
console.log("[artcraftx] Rust/Tauri: native IPC; automatic rebuild and app restart enabled.");
|
||||
console.log("[artcraftx] Press Ctrl-C to stop both.\n");
|
||||
|
||||
rust = spawn("cargo", [
|
||||
"tauri", "dev",
|
||||
"--no-dev-server",
|
||||
"--config", configPath,
|
||||
"--config", JSON.stringify(overrides),
|
||||
], {
|
||||
cwd: rootDir,
|
||||
// Start a separate process group, but keep all output in this terminal.
|
||||
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(`[artcraftx] Rust/Tauri exited (${signal ?? code}).`);
|
||||
void stop(code ?? 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(fail);
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
// 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 { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = fileURLToPath(new URL("../../", import.meta.url));
|
||||
const launcher = path.join(rootDir, "script/unix_dev.sh");
|
||||
|
||||
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(), "artcraftx-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), env: process.env, cwd: process.cwd(),
|
||||
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));
|
||||
}
|
||||
|
||||
test("skips occupied ports, pairs Tauri with real Vite/HMR, and cleans up on Ctrl-C", { timeout: 20000 }, async (t) => {
|
||||
const occupied = await occupyPort(t);
|
||||
const startingPort = occupied.address().port;
|
||||
const run = await fixture(t, { ARTCRAFTX_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/artcraftx"));
|
||||
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: 20000 }, 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: 20000 }, 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: 20000 }, async (t) => {
|
||||
const run = await fixture(t, { ARTCRAFTX_DEV_PORT: "65536" });
|
||||
assert.deepEqual(await run.exited, [1, null]);
|
||||
assert.match(run.output(), /ARTCRAFTX_DEV_PORT must be an integer/);
|
||||
});
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
#!/usr/bin/env bash
|
||||
# Start the ArtCraftX frontend and Tauri app 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/unix_dev.sh
|
||||
|
||||
Starts Vite with JavaScript/CSS live reload and Tauri with Rust rebuild/restart.
|
||||
Searches for a free loopback port starting at ARTCRAFTX_DEV_PORT (default: 5183).
|
||||
Rust uses Tauri IPC; it 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 if node_modules is missing.
|
||||
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
|
||||
npm install
|
||||
fi
|
||||
|
||||
exec node "${frontend_path}/scripts/unix-dev.mjs"
|
||||
Loading…
Reference in New Issue