From 7423555c4635601ec576d5f897f33631d7ab9876 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 18 Aug 2026 17:18:56 -0700 Subject: [PATCH] feat(dev): pnpm dev --tunnel publishes the dev server over a quick tunnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/feat-dev-tunnel.md | 7 ++ docs/contributing.md | 38 ++++++ .../src/__tests__/dev-with-memory-lib.test.ts | 52 +++++++++ scripts/dev-with-memory-lib.mjs | 45 +++++++ scripts/dev-with-memory.mjs | 23 +++- scripts/lib/dev-tunnel.mjs | 110 ++++++++++++++++++ 6 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 .changeset/feat-dev-tunnel.md create mode 100644 scripts/lib/dev-tunnel.mjs diff --git a/.changeset/feat-dev-tunnel.md b/.changeset/feat-dev-tunnel.md new file mode 100644 index 0000000000..a198525325 --- /dev/null +++ b/.changeset/feat-dev-tunnel.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: New `pnpm dev --tunnel` publishes a dev server through a Cloudflare quick tunnel and prints the URL. +category: feature +dev: Adds `--tunnel` / `--tunnel=PORT` (and `FUSION_DEV_TUNNEL`/`FUSION_DEV_TUNNEL_PORT`) to the dev wrapper, plus `scripts/lib/dev-tunnel.mjs`. Port defaults to `PORT` or 4040 via `resolveDevTunnelPort`. Quick tunnels are viable here because a dev server is HTTP — TCP endpoints would need a card (ngrok) or a domain plus Zero Trust (Cloudflare). Tunnel failure is non-fatal, watch-mode restarts reuse the existing tunnel so a shared link stays valid, and `--tunnel` consumes a following token only when numeric so `--tunnel dashboard` still forwards its argument. Documented in docs/contributing.md. diff --git a/docs/contributing.md b/docs/contributing.md index 372dd6327e..ca5d72d3fe 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -76,6 +76,8 @@ pnpm dev:watch # dashboard + engine; gracefully restart runtime source a FUSION_DEV_PREBUILD=full pnpm dev dashboard # production-like full workspace prebuild pnpm dev:ui # dashboard dev server only pnpm dev:hmr # dashboard API + Vite UI HMR + graceful runtime source restarts +pnpm dev --tunnel # dev server + a public Cloudflare quick-tunnel URL (see below) +pnpm dev --tunnel=5173 # tunnel a specific port (e.g. a Vite server) instead of the dashboard pnpm lint # lint all packages pnpm test # merge-gate suite + changed-only affected tests (bounded; never full-suite) pnpm test:gate # the merge gate: curated engine-core suite + CI-shape test @@ -87,6 +89,42 @@ pnpm verify:workspace # deep opt-in verification: lint -> test:full -> build pnpm typecheck # workspace typechecks ``` +## Sharing a dev server (`pnpm dev --tunnel`) + +When Fusion runs somewhere other than your laptop — a container, a shared box, a remote host — a dev +server bound inside it is unreachable from your own browser. `--tunnel` publishes it through a +**Cloudflare quick tunnel** and prints the URL: + +```bash +pnpm dev --tunnel # tunnels the dashboard port (PORT, default 4040) +pnpm dev --tunnel=5173 # tunnels a specific port, e.g. a Vite dev server +pnpm dev --tunnel dashboard # tunnel the default port AND run the dashboard +FUSION_DEV_TUNNEL=1 pnpm dev # same, from the environment +``` + +``` + ┌ dev server tunnel (public, unauthenticated) + │ https://mic-relatively-jewelry-belly.trycloudflare.com → http://localhost:5173 + └ anyone with this URL can reach your dev server +``` + +Requires `cloudflared` on PATH (the Docker image ships it). Quick tunnels need no account, domain, or +payment card **because a dev server is HTTP** — the TCP endpoints that something like SSH would need +require a card (ngrok) or a domain plus Zero Trust (Cloudflare), which is why this flag exists only +for HTTP. + +Behaviour worth knowing: + +- **The URL is public and unauthenticated.** Anyone holding it reaches the dev server. Use it for + sharing a preview, not for anything sensitive. Tunnelling the DASHBOARD port is different — the + dashboard enforces its own bearer token, so a tunnel to it still returns 401 without credentials. +- **A failed tunnel never takes the dev server down.** If `cloudflared` is missing or no URL is + published, it logs and carries on; losing a preview URL must not cost you your dev loop. +- **Restarts reuse the tunnel.** In `--watch` mode a fresh quick tunnel would hand out a different + hostname on every reload, invalidating the link you already shared. +- **`--tunnel` only consumes a following token when it is numeric**, so `pnpm dev --tunnel dashboard` + still forwards `dashboard` to the dev command. + ## Deterministic workspace verification bootstrap Fusion codifies workspace verification as a deterministic contract: diff --git a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts index 418a3d8ef6..6cb02d19fa 100644 --- a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts +++ b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts @@ -6,8 +6,10 @@ import { getPrebuildCommand, normalizePrebuildMode, parseDevWrapperArgs, + resolveDevTunnelPort, resolvePrebuildMode, } from "../../../../scripts/dev-with-memory-lib.mjs"; +import { extractQuickTunnelUrl } from "../../../../scripts/lib/dev-tunnel.mjs"; import { createDevSourceWatcher, isRestartableSourceFile, @@ -55,6 +57,8 @@ describe("dev-with-memory prebuild options", () => { requestedPrebuild: "none", watchSource: false, watchSourceFromFlag: false, + tunnel: false, + tunnelPort: undefined, }); }); @@ -65,6 +69,8 @@ describe("dev-with-memory prebuild options", () => { requestedPrebuild: "auto", watchSource: true, watchSourceFromFlag: true, + tunnel: false, + tunnelPort: undefined, }); }); @@ -320,4 +326,50 @@ describe("development source restart watcher", () => { expect(closes[1]).toHaveBeenCalledOnce(); expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("close failed")); }); + + /* + FNXC:DevTunnel 2026-08-18-23:40: + `--tunnel` exposes the dev server through a Cloudflare quick tunnel, for the case where Fusion runs + on a remote box (a container, a shared machine) and the operator needs to view the dev server from + their own browser. A quick tunnel needs no account, domain, or card BECAUSE the dev server is HTTP. + + The subtle case: `--tunnel` optionally takes a port, so the parser must not swallow the next token + when it is a dev-command argument rather than a port — `--tunnel dashboard` means "tunnel the + dashboard's default port and run the dashboard", not "tunnel port NaN". + */ + describe("dev tunnel flag", () => { + it("enables the tunnel without consuming a following non-port argument", () => { + expect(parseDevWrapperArgs(["--tunnel", "dashboard"], {})).toMatchObject({ + args: ["dashboard"], + tunnel: true, + tunnelPort: undefined, + }); + }); + + it("accepts a port as a separate token or inline", () => { + expect(parseDevWrapperArgs(["--tunnel", "5173"], {})).toMatchObject({ tunnel: true, tunnelPort: 5173, args: [] }); + expect(parseDevWrapperArgs(["--tunnel=3000"], {})).toMatchObject({ tunnel: true, tunnelPort: 3000, args: [] }); + }); + + it("rejects a non-numeric inline port instead of tunnelling something arbitrary", () => { + expect(() => parseDevWrapperArgs(["--tunnel=frontend"], {})).toThrow(/Expected a port number/); + }); + + it("stays off by default and can be enabled from the environment", () => { + expect(parseDevWrapperArgs(["dashboard"], {})).toMatchObject({ tunnel: false }); + expect(parseDevWrapperArgs(["dashboard"], { FUSION_DEV_TUNNEL: "1" })).toMatchObject({ tunnel: true }); + }); + + it("targets the dashboard port unless told otherwise", () => { + expect(resolveDevTunnelPort(undefined, {})).toBe(4040); + expect(resolveDevTunnelPort(undefined, { PORT: "8080" })).toBe(8080); + // An explicit --tunnel=PORT wins, so a Vite server can be exposed while PORT names the dashboard. + expect(resolveDevTunnelPort(5173, { PORT: "8080" })).toBe(5173); + }); + + it("recognises the cloudflare quick-tunnel hostname in agent output", () => { + expect(extractQuickTunnelUrl("INF | https://neat-fox-tree.trycloudflare.com |")).toBe("https://neat-fox-tree.trycloudflare.com"); + expect(extractQuickTunnelUrl("INF Registered tunnel connection")).toBeNull(); + }); + }); }); diff --git a/scripts/dev-with-memory-lib.mjs b/scripts/dev-with-memory-lib.mjs index 51b531faa2..6aa38ee4a3 100644 --- a/scripts/dev-with-memory-lib.mjs +++ b/scripts/dev-with-memory-lib.mjs @@ -126,6 +126,14 @@ export function parseDevWrapperArgs(rawArgs, env = process.env) { let requestedPrebuild = env.FUSION_DEV_PREBUILD ?? "auto"; let watchSource = env.FUSION_DEV_WATCH === "1"; let watchSourceFromFlag = false; + /* + FNXC:DevTunnel 2026-08-18-23:40: + `--tunnel` exposes the dev server through a Cloudflare quick tunnel, for working inside a remote + Fusion (container or shared box) and needing to view the dev server from your own browser. + `--tunnel=PORT` targets a port other than the dashboard's (e.g. a Vite server on 5173). + */ + let tunnel = env.FUSION_DEV_TUNNEL === "1"; + let tunnelPort = env.FUSION_DEV_TUNNEL_PORT ? Number(env.FUSION_DEV_TUNNEL_PORT) : undefined; for (let i = 0; i < rawArgs.length; i += 1) { const arg = rawArgs[i]; @@ -160,6 +168,28 @@ export function parseDevWrapperArgs(rawArgs, env = process.env) { continue; } + if (arg === "--tunnel") { + tunnel = true; + const next = rawArgs[i + 1]; + // Accept `--tunnel 5173` only when the next token is a port, so `--tunnel dashboard` still + // forwards `dashboard` to the dev command instead of swallowing it. + if (next && /^\d+$/.test(next)) { + tunnelPort = Number(next); + i += 1; + } + continue; + } + + if (arg.startsWith("--tunnel=")) { + tunnel = true; + const value = arg.slice("--tunnel=".length); + if (!/^\d+$/.test(value)) { + throw new Error(`Invalid value for --tunnel: ${value}. Expected a port number.`); + } + tunnelPort = Number(value); + continue; + } + args.push(arg); } @@ -169,9 +199,24 @@ export function parseDevWrapperArgs(rawArgs, env = process.env) { requestedPrebuild: normalizePrebuildMode(requestedPrebuild), watchSource, watchSourceFromFlag, + tunnel, + tunnelPort, }; } +/** + * Port the tunnel should point at. + * + * FNXC:DevTunnel 2026-08-18-23:40: defaults to the dashboard's port, because `pnpm dev` with no + * target starts the dashboard. An explicit `--tunnel=PORT` wins so a Vite dev server (or anything + * else the operator started) can be exposed instead. + */ +export function resolveDevTunnelPort(tunnelPort, env = process.env) { + if (tunnelPort) return tunnelPort; + const fromEnv = Number(env.PORT); + return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 4040; +} + export function resolvePrebuildMode(requestedPrebuild, forwardedArgs) { const mode = normalizePrebuildMode(requestedPrebuild); if (mode !== "auto") { diff --git a/scripts/dev-with-memory.mjs b/scripts/dev-with-memory.mjs index 3c62ff84ec..1687286701 100644 --- a/scripts/dev-with-memory.mjs +++ b/scripts/dev-with-memory.mjs @@ -14,9 +14,11 @@ import { 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"; @@ -31,7 +33,7 @@ try { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); } -const { inspectFlags, args, requestedPrebuild, watchSourceFromFlag } = parsedArgs; +const { inspectFlags, args, requestedPrebuild, watchSourceFromFlag, tunnel, tunnelPort } = parsedArgs; let { watchSource } = parsedArgs; // NODE_OPTIONS is shared with every spawned node process (build + run + @@ -82,6 +84,7 @@ propagates unchanged (no crash-restart loop here — `--supervise` owns that). */ const RESTART_EXIT_CODE = 86; let appChild; +let devTunnel; let sourceWatcher; const watchRestart = createDevWatchRestartCoordinator(); @@ -114,6 +117,23 @@ function runApp(extraArgs) { }, }); 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(); @@ -129,6 +149,7 @@ function runApp(extraArgs) { } return; } + devTunnel?.stop?.(); process.exit(c ?? 1); }); } diff --git a/scripts/lib/dev-tunnel.mjs b/scripts/lib/dev-tunnel.mjs new file mode 100644 index 0000000000..f2e88802ac --- /dev/null +++ b/scripts/lib/dev-tunnel.mjs @@ -0,0 +1,110 @@ +/* +FNXC:DevTunnel 2026-08-18-23:40: +`pnpm dev --tunnel` exposes the dev server through a Cloudflare quick tunnel. + +The case this exists for: someone working inside a remote Fusion (a container, a shared box) starts a +dev server there and needs to LOOK at it from their own browser. The dev server is bound inside that +machine, so without a tunnel the only options are port publishing or a VPN — both of which need +cooperation from whoever owns the host. + +Cloudflare QUICK tunnels are the right tool precisely because a dev server is HTTP: they need no +account, no domain, and no card (the TCP endpoints that SSH would have required need all three). +The trade is that the hostname is random and lives only as long as the process. + +NOT a production exposure path: a quick tunnel is unauthenticated, so anyone with the URL reaches the +dev server. It is printed loudly for that reason. +*/ + +import { spawn } from "node:child_process"; + +/** Cloudflare prints the assigned hostname once the edge accepts the tunnel. */ +const QUICK_TUNNEL_URL = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i; + +/** How long to wait for the URL before giving up and leaving the dev server running. */ +const DEFAULT_URL_TIMEOUT_MS = 45_000; + +export function extractQuickTunnelUrl(text) { + const match = QUICK_TUNNEL_URL.exec(String(text ?? "")); + return match ? match[0] : null; +} + +/** + * Start a Cloudflare quick tunnel for a local port. + * + * Resolves once the public URL is known, or with `url: null` if cloudflared never printed one — + * the dev server keeps running either way, since losing the tunnel must not take the dev loop down. + */ +export async function startDevTunnel({ + port, + log = console, + spawnFn = spawn, + timeoutMs = DEFAULT_URL_TIMEOUT_MS, +} = {}) { + if (!port) throw new Error("startDevTunnel requires a port"); + + const child = spawnFn( + "cloudflared", + ["tunnel", "--no-autoupdate", "--url", `http://localhost:${port}`], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + + let settled = false; + let url = null; + + const stop = () => { + if (!child.killed) child.kill("SIGTERM"); + }; + + const urlPromise = new Promise((resolve) => { + const finish = (value) => { + if (settled) return; + settled = true; + url = value; + resolve(value); + }; + + const scan = (chunk) => { + const found = extractQuickTunnelUrl(chunk); + if (found) finish(found); + }; + + child.stdout?.on("data", scan); + // cloudflared writes its banner (including the URL) to stderr. + child.stderr?.on("data", scan); + + child.on("error", (error) => { + const hint = error?.code === "ENOENT" + ? "cloudflared is not installed — install it or drop --tunnel" + : error?.message; + log.error?.(`[fusion:dev] tunnel failed to start: ${hint}`); + finish(null); + }); + + child.on("exit", (code) => { + if (!settled) { + log.error?.(`[fusion:dev] tunnel exited before publishing a URL (code ${code})`); + finish(null); + } + }); + + const timer = setTimeout(() => { + if (!settled) { + log.error?.(`[fusion:dev] tunnel did not publish a URL within ${Math.round(timeoutMs / 1000)}s`); + finish(null); + } + }, timeoutMs); + timer.unref?.(); + }); + + await urlPromise; + + if (url) { + log.log?.(""); + log.log?.(` ┌ dev server tunnel (public, unauthenticated)`); + log.log?.(` │ ${url} → http://localhost:${port}`); + log.log?.(` └ anyone with this URL can reach your dev server`); + log.log?.(""); + } + + return { url, stop, child }; +}