fix(FN-000): improve local startup UX

This commit is contained in:
Aron Prins
2026-05-11 09:52:23 +02:00
parent 80cb1e81bc
commit 12ae8f77fb
12 changed files with 244 additions and 40 deletions

View File

@@ -5,8 +5,8 @@
* live API/WebSocket backend.
*
* Two processes:
* 1. API: `pnpm dev dashboard --no-auth --port <API_PORT>`
* (handles the full build, typecheck, engine, etc.)
* 1. API: `pnpm dev --prebuild=none dashboard --no-auth --port <API_PORT>`
* (source-mode API/engine; Vite serves the browser UI)
* 2. Vite: `vite dev` in packages/dashboard
* (serves app/ with HMR; proxies /api and WS to the API)
*
@@ -15,7 +15,7 @@
* code) still require restarting this script.
*
* Env:
* FUSION_API_PORT API port (default 4040). Vite's proxy reads the same
* FUSION_API_PORT API port (default 4050). Vite's proxy reads the same
* var so both sides stay in sync.
* FUSION_VITE_PORT Vite dev port (default 5173).
*/
@@ -28,7 +28,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..");
const dashboardDir = resolve(repoRoot, "packages/dashboard");
const API_PORT = globalThis.process.env.FUSION_API_PORT ?? "4040";
const API_PORT = globalThis.process.env.FUSION_API_PORT ?? "4050";
const VITE_PORT = globalThis.process.env.FUSION_VITE_PORT ?? "5173";
const children = [];
@@ -88,13 +88,13 @@ globalThis.process.on("SIGTERM", () => shutdown(0));
console.log(`[dev-hmr] starting API on :${API_PORT} + vite on :${VITE_PORT}`);
console.log(`[dev-hmr] open http://localhost:${VITE_PORT} for HMR (not the API URL)`);
// API: green. Runs the existing memory-aware dev entry so the engine, build,
// and typecheck all happen exactly as they do for `pnpm dev dashboard`.
// API: green. Vite owns the browser UI in this mode, so skip the dashboard
// client prebuild and run the API/engine directly from source.
launch(
"api",
"32",
"pnpm",
["dev", "dashboard", "--no-auth", "--port", API_PORT, "--host", "127.0.0.1"],
["dev", "--prebuild=none", "dashboard", "--no-auth", "--port", API_PORT, "--host", "127.0.0.1"],
{ cwd: repoRoot, env: { ...globalThis.process.env, FUSION_API_PORT: API_PORT } },
);

View File

@@ -16,3 +16,81 @@ export function buildDevNodeArgs({
...args,
];
}
const VALID_PREBUILD_MODES = new Set(["auto", "none", "client", "full"]);
export function normalizePrebuildMode(value) {
const mode = String(value || "auto").toLowerCase();
if (!VALID_PREBUILD_MODES.has(mode)) {
throw new Error(`Invalid prebuild mode "${value}". Expected one of: auto, none, client, full.`);
}
return mode;
}
export function parseDevWrapperArgs(rawArgs, env = process.env) {
const inspectFlags = [];
const args = [];
let requestedPrebuild = env.FUSION_DEV_PREBUILD || "auto";
for (let i = 0; i < rawArgs.length; i += 1) {
const arg = rawArgs[i];
if (arg === "--inspect" || arg === "--inspect-brk" || arg.startsWith("--inspect=") || arg.startsWith("--inspect-brk=")) {
inspectFlags.push(arg);
continue;
}
if (arg === "--prebuild") {
const value = rawArgs[i + 1];
if (!value) {
throw new Error("Missing value for --prebuild. Expected one of: auto, none, client, full.");
}
requestedPrebuild = value;
i += 1;
continue;
}
if (arg.startsWith("--prebuild=")) {
requestedPrebuild = arg.slice("--prebuild=".length);
continue;
}
if (arg === "--skip-build") {
requestedPrebuild = "none";
continue;
}
args.push(arg);
}
return {
inspectFlags,
args,
requestedPrebuild: normalizePrebuildMode(requestedPrebuild),
};
}
export function resolvePrebuildMode(requestedPrebuild, forwardedArgs) {
const mode = normalizePrebuildMode(requestedPrebuild);
if (mode !== "auto") {
return mode;
}
const command = forwardedArgs[0] ?? "dashboard";
return command === "dashboard" ? "client" : "none";
}
export function getPrebuildCommand(mode) {
switch (normalizePrebuildMode(mode)) {
case "full":
return { command: "pnpm", args: ["build"], label: "workspace build" };
case "client":
return {
command: "pnpm",
args: ["--filter", "@fusion/dashboard", "build:client"],
label: "dashboard client build",
};
case "none":
case "auto":
return null;
}
}

View File

@@ -3,12 +3,17 @@
* Memory-aware development entrypoint for Fusion.
*
* This script increases the Node.js heap size to prevent memory pressure
* during the initial build/start sequence, while preserving argument
* during the optional prebuild/start sequence, while preserving argument
* pass-through for documented invocations like `pnpm dev dashboard`.
*
* Cross-platform: Works on Windows, macOS, and Linux.
*/
import { buildDevNodeArgs } from "./dev-with-memory-lib.mjs";
import {
buildDevNodeArgs,
getPrebuildCommand,
parseDevWrapperArgs,
resolvePrebuildMode,
} from "./dev-with-memory-lib.mjs";
// Set increased heap size (8GB) to prevent OOM during initial build/start
const MEMORY_MB = process.env.FUSION_DEV_MEMORY_MB || "8192";
@@ -16,21 +21,14 @@ const MEMORY_MB = process.env.FUSION_DEV_MEMORY_MB || "8192";
// Spawn the actual dev command with all arguments passed through
const { spawn } = await import("child_process");
const rawArgs = process.argv.slice(2);
// --inspect / --inspect-brk / --inspect=PORT enables the Node inspector.
// Strip these from forwarded args so they don't reach the dashboard CLI
// parser. We pass them as CLI flags directly to node (NOT via NODE_OPTIONS),
// because NODE_OPTIONS is inherited by every grandchild process — every
// vitest / agent / claude subprocess would then try to bind 9229 and fail.
const inspectFlags = [];
const args = [];
for (const a of rawArgs) {
if (a === "--inspect" || a === "--inspect-brk" || a.startsWith("--inspect=") || a.startsWith("--inspect-brk=")) {
inspectFlags.push(a);
} else {
args.push(a);
}
let parsedArgs;
try {
parsedArgs = parseDevWrapperArgs(rawArgs);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
const { inspectFlags, args, requestedPrebuild } = parsedArgs;
// NODE_OPTIONS is shared with every spawned node process (build + run +
// agents). Heap size belongs here. Inspector flags do NOT — see comment above.
@@ -46,6 +44,8 @@ const needsDevHostInjection =
const forwardedArgs = needsDevHostInjection
? [...args, "--host", "0.0.0.0"]
: args;
const prebuildMode = resolvePrebuildMode(requestedPrebuild, forwardedArgs);
const prebuildCommand = getPrebuildCommand(prebuildMode);
// Resolve absolute paths to tsx loader so they survive shell quoting.
// Use Node's resolver instead of hardcoding the pnpm version-specific path.
@@ -73,15 +73,70 @@ function runApp(extraArgs) {
tsx.on("close", (c) => process.exit(c ?? 1));
}
// If no args, run default: build + CLI
if (forwardedArgs.length === 0) {
const pnpm = spawn("pnpm", ["build"], { stdio: "inherit", shell: true });
pnpm.on("close", (code) => {
if (code !== 0) process.exit(code ?? 1);
runApp([]);
});
async function warnIfSourceVersionBehind() {
if (process.env.FUSION_SKIP_STARTUP_UPDATE_PREFLIGHT === "1") {
return;
}
let currentVersion;
try {
const { readFile } = await import("node:fs/promises");
const pkg = JSON.parse(await readFile(path.resolve(process.cwd(), "packages/cli/package.json"), "utf8"));
currentVersion = typeof pkg.version === "string" ? pkg.version : undefined;
} catch {
return;
}
if (!currentVersion) return;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1_500);
let payload;
try {
const response = await fetch("https://registry.npmjs.org/@runfusion%2Ffusion", {
signal: controller.signal,
});
payload = await response.json();
} finally {
clearTimeout(timeout);
}
const latestVersion = payload?.["dist-tags"]?.latest;
if (typeof latestVersion !== "string") return;
const currentParts = currentVersion.split(".").map((part) => Number.parseInt(part, 10) || 0);
const latestParts = latestVersion.split(".").map((part) => Number.parseInt(part, 10) || 0);
let latestIsNewer = false;
for (let i = 0; i < Math.max(currentParts.length, latestParts.length, 3); i += 1) {
const latest = latestParts[i] ?? 0;
const current = currentParts[i] ?? 0;
if (latest > current) {
latestIsNewer = true;
break;
}
if (latest < current) {
break;
}
}
if (latestIsNewer) {
console.warn(
`\n[fusion] This source checkout is v${currentVersion}, but npm latest is v${latestVersion}. ` +
"If you meant to run the latest Fusion, pull/switch branches before startup.\n",
);
}
} catch {
// Best-effort only. Startup must not depend on the registry.
}
}
await warnIfSourceVersionBehind();
if (!prebuildCommand) {
runApp(forwardedArgs);
} else {
const build = spawn("pnpm", ["build"], { stdio: "inherit", shell: true });
console.log(`[fusion] Running ${prebuildCommand.label} (${prebuildMode}) before source startup...`);
const build = spawn(prebuildCommand.command, prebuildCommand.args, { stdio: "inherit", shell: true });
build.on("close", (code) => {
if (code !== 0) process.exit(code ?? 1);
runApp(forwardedArgs);

View File

@@ -31,6 +31,7 @@ Options:
--host <host> Host to bind. Default: 127.0.0.1.
--auth Keep bearer-token auth enabled on localhost.
--no-auth Disable auth even for a non-localhost host.
--prebuild <mode> Startup prebuild: client, none, or full. Default: client.
--skip-install Do not auto-run pnpm install when node_modules is missing.
--skip-register Do not auto-register this repo in Fusion's project registry.
--allow-4040 Allow using reserved dashboard port 4040.
@@ -63,6 +64,7 @@ function parseArgs(argv) {
host: "127.0.0.1",
auth: false,
noAuth: false,
prebuild: "client",
skipInstall: false,
skipRegister: false,
allow4040: false,
@@ -107,6 +109,14 @@ function parseArgs(argv) {
case "--no-auth":
opts.noAuth = true;
break;
case "--prebuild": {
const value = argv[++i];
if (!["none", "client", "full"].includes(value)) {
fail("Invalid --prebuild value. Expected one of: none, client, full.");
}
opts.prebuild = value;
break;
}
case "--skip-install":
opts.skipInstall = true;
break;
@@ -301,12 +311,18 @@ function shouldDisableAuth(opts) {
function printSummary(opts, port, dashboardArgs) {
const mode = opts.engine ? "dashboard + AI engine" : "dashboard/API only";
const auth = dashboardArgs.includes("--no-auth") ? "disabled" : "enabled";
const prebuild = opts.prebuild === "client"
? "dashboard client only"
: opts.prebuild === "full"
? "full workspace"
: "skipped";
console.log();
ok(`Starting ${mode}`);
console.log(` URL: http://${opts.host === "127.0.0.1" ? "localhost" : opts.host}:${port}`);
console.log(` Host: ${opts.host}`);
console.log(` Auth: ${auth}`);
console.log(` Cmd: node scripts/dev-with-memory.mjs ${dashboardArgs.join(" ")}`);
console.log(` Prebuild: ${prebuild}`);
console.log(` Cmd: node scripts/dev-with-memory.mjs --prebuild=${opts.prebuild} ${dashboardArgs.join(" ")}`);
console.log();
}
@@ -346,7 +362,7 @@ async function main() {
const child = spawn(process.execPath, ["scripts/dev-with-memory.mjs", ...dashboardArgs], {
cwd: repoRoot,
stdio: "inherit",
env: process.env,
env: { ...process.env, FUSION_DEV_PREBUILD: opts.prebuild },
});
child.on("exit", (code, signal) => {