Files
fusion/scripts/dev-with-memory.mjs
gsxdsm 7423555c46 feat(dev): pnpm dev --tunnel publishes the dev server over a quick tunnel
Operator case: someone works inside a remote Fusion (a container, a shared box),
starts a dev server there, and needs to view it from their own browser. The dev
server binds inside that machine, so without a tunnel the only options are port
publishing or a VPN — both needing cooperation from whoever owns the host.

  pnpm dev --tunnel            # tunnels the dashboard port (PORT, default 4040)
  pnpm dev --tunnel=5173       # tunnels a Vite dev server instead
  pnpm dev --tunnel dashboard  # tunnel the default port AND run the dashboard
  FUSION_DEV_TUNNEL=1 pnpm dev

Cloudflare QUICK tunnels are usable here precisely because a dev server is HTTP:
no account, no domain, no card. The TCP endpoints that SSH would have needed
require a card (ngrok) or a domain plus Zero Trust (Cloudflare) — that asymmetry
is why this exists for HTTP only, and it is recorded in the module header so the
next person does not retry the SSH variant.

Design decisions:
- Tunnel failure is NON-FATAL. A missing cloudflared or a tunnel that never
  publishes a URL logs and is skipped; losing a preview URL must never cost the
  operator their dev loop.
- Watch-mode restarts reuse the existing tunnel. A fresh quick tunnel hands out a
  different hostname each time, which would invalidate an already-shared link.
- `--tunnel` consumes a following token only when it is numeric, so
  `--tunnel dashboard` forwards `dashboard` to the dev command rather than
  tunnelling port NaN. That is the bug this flag shape invites, so it is tested.

Verified end to end in a container: a dev server bound to 127.0.0.1 inside it was
fetched from the public internet through the tunnel (200, correct body). Also
confirmed that tunnelling the DASHBOARD port does not weaken auth — unauthenticated
requests through the tunnel return 401 for /api/tasks, /api/settings and
/api/artifacts, with only /api/health open by design.

Adding two fields to parseDevWrapperArgs' return broke two existing strict toEqual
assertions; those were updated rather than loosened to toMatchObject. 27 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:18:56 -07:00

249 lines
9.4 KiB
JavaScript

#!/usr/bin/env node
/**
* Memory-aware development entrypoint for Fusion.
*
* This script increases the Node.js heap size to prevent memory pressure
* 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 {
buildForwardedDevArgs,
buildDevNodeArgs,
createDevWatchRestartCoordinator,
getPrebuildCommand,
parseDevWrapperArgs,
resolveDevTunnelPort,
resolvePrebuildMode,
} from "./dev-with-memory-lib.mjs";
import { createDevSourceWatcher } from "./lib/dev-source-watch.mjs";
import { startDevTunnel } from "./lib/dev-tunnel.mjs";
// Set increased heap size (8GB) to prevent OOM during initial build/start
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);
let parsedArgs;
try {
parsedArgs = parseDevWrapperArgs(rawArgs);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
const { inspectFlags, args, requestedPrebuild, watchSourceFromFlag, tunnel, tunnelPort } = parsedArgs;
let { watchSource } = parsedArgs;
// NODE_OPTIONS is shared with every spawned node process (build + run +
// agents). Heap size belongs here. Inspector flags do NOT — see comment above.
const nodeOptions = `--max-old-space-size=${MEMORY_MB} ${process.env.NODE_OPTIONS || ""}`.trim();
process.env.NODE_OPTIONS = nodeOptions;
// In dev we bind the dashboard to 0.0.0.0 so the server is reachable from
// mobile devices and other machines on the LAN for testing. Production
// builds default to 127.0.0.1; this override only applies when starting
// the dashboard via `pnpm dev dashboard` and only if no --host was passed.
const forwardedArgs = buildForwardedDevArgs(args);
if (watchSource && forwardedArgs[0] !== "dashboard") {
if (watchSourceFromFlag) {
console.error("[fusion:dev] --watch is supported for the dashboard engine process only");
process.exit(1);
}
watchSource = false;
}
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.
const { createRequire } = await import("node:module");
const path = await import("node:path");
const require = createRequire(import.meta.url);
const tsxPkgJson = require.resolve("tsx/package.json");
const tsxDir = path.dirname(tsxPkgJson);
const PRELOAD = path.join(tsxDir, "dist", "preflight.cjs");
const LOADER = path.join(tsxDir, "dist", "loader.mjs");
const ENTRY = path.resolve(process.cwd(), "packages/cli/src/bin.ts");
// Spawn node directly (no shell) so the inspector attaches to the real app
// process and there's no parent/child wrapper consuming --inspect.
// Inspector flags are CLI args here so they apply only to this process and
// don't propagate to grandchildren via NODE_OPTIONS.
/*
FNXC:SystemPanel 2026-07-12-10:45:
This wrapper is the supervising parent for `pnpm dev` / `pnpm start`, so it is
where the dashboard System panel's "Restart"/"Rebuild & restart" actions land:
the child exits with FUSION_RESTART_EXIT_CODE (86 — keep in sync with
packages/core/src/process-supervisor.ts) and we respawn the same command
immediately, keeping the same terminal/TTY so the TUI comes back seamlessly.
FUSION_RESTART_SUPERVISED=1 tells the child a respawning parent exists, which
is what makes the dashboard advertise restart support. Any other exit code
propagates unchanged (no crash-restart loop here — `--supervise` owns that).
*/
const RESTART_EXIT_CODE = 86;
let appChild;
let devTunnel;
let sourceWatcher;
const watchRestart = createDevWatchRestartCoordinator();
function ensureSourceWatcher() {
if (!watchSource || sourceWatcher) return;
sourceWatcher = createDevSourceWatcher({
rootDir: process.cwd(),
onRestart: (paths) => watchRestart.request(paths),
});
console.log(`[fusion:dev] source watch active (${sourceWatcher.watchedPaths.join(", ")})`);
}
function runApp(extraArgs) {
const tsx = spawn(process.execPath, buildDevNodeArgs({
inspectFlags,
preload: PRELOAD,
loader: LOADER,
entry: ENTRY,
args: extraArgs,
}), {
stdio: watchSource ? ["inherit", "inherit", "inherit", "ipc"] : "inherit",
// FNXC:SystemPanel 2026-07-25-10:05: stamp the supervisor pid alongside the
// flag so the child can tell a real supervising parent from an inherited
// copy of the variable (see hasLiveSupervisingParent in commands/dashboard.ts).
env: {
...process.env,
FUSION_RESTART_SUPERVISED: "1",
FUSION_SUPERVISOR_PID: String(process.pid),
...(watchSource ? { FUSION_DEV_WATCH: "1" } : {}),
},
});
appChild = tsx;
/*
FNXC:DevTunnel 2026-08-18-23:40:
Started AFTER the dev child so the tunnel points at a port something is actually about to serve,
and torn down with it. Deliberately fire-and-forget: a tunnel that fails to come up logs and is
skipped rather than taking the dev loop down with it — losing a preview URL must never cost the
operator their dev server. Restarts (watch mode) reuse the existing tunnel, since the port is
unchanged and a fresh quick tunnel would hand out a different hostname every reload.
*/
if (tunnel && !devTunnel) {
const port = resolveDevTunnelPort(tunnelPort);
devTunnel = { url: null, stop: () => {} };
void startDevTunnel({ port })
.then((started) => { devTunnel = started; })
.catch((error) => {
console.error(`[fusion:dev] tunnel error: ${error instanceof Error ? error.message : String(error)}`);
});
}
watchRestart.attach(tsx);
tsx.on("message", (message) => watchRestart.onMessage(message));
ensureSourceWatcher();
tsx.on("close", (c) => {
const sourceRestart = watchRestart.detach(tsx);
if (appChild === tsx) appChild = undefined;
if (c === RESTART_EXIT_CODE) {
console.log("[fusion:dev] restart requested — restarting…");
if (sourceRestart && prebuildCommand) {
runPrebuild(() => runApp(extraArgs));
} else {
runApp(extraArgs);
}
return;
}
devTunnel?.stop?.();
process.exit(c ?? 1);
});
}
function runPrebuild(onSuccess) {
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);
onSuccess();
});
}
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();
// FNXC:DevWorkflow 2026-06-18-16:50:
// FN-6638 stale-dist guard. Warn (loudly, best-effort) when built dist/ is older
// than src/ so a never-rebuilt/never-restarted process does not silently run
// phantom-old code. When a prebuild is about to run it will refresh dist, so the
// check is informational there; for --prebuild none / dist-resolving consumers
// it is the safety net. Never let the check break startup.
async function warnIfDistStale() {
if (process.env.FUSION_SKIP_DIST_FRESHNESS_CHECK === "1") return;
try {
const { computeDistStaleness, formatDistStalenessWarning } = await import("./lib/dist-freshness.mjs");
const warning = formatDistStalenessWarning(computeDistStaleness({ rootDir: process.cwd() }));
if (warning) console.warn(warning);
} catch {
// Best-effort only. Startup must not depend on the freshness check.
}
}
await warnIfDistStale();
if (!prebuildCommand) {
runApp(forwardedArgs);
} else {
runPrebuild(() => runApp(forwardedArgs));
}