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>
This commit is contained in:
gsxdsm
2026-08-18 17:18:56 -07:00
parent b18c9d7594
commit 7423555c46
6 changed files with 274 additions and 1 deletions

View File

@@ -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.

View File

@@ -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 FUSION_DEV_PREBUILD=full pnpm dev dashboard # production-like full workspace prebuild
pnpm dev:ui # dashboard dev server only pnpm dev:ui # dashboard dev server only
pnpm dev:hmr # dashboard API + Vite UI HMR + graceful runtime source restarts 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 lint # lint all packages
pnpm test # merge-gate suite + changed-only affected tests (bounded; never full-suite) 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 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 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 ## Deterministic workspace verification bootstrap
Fusion codifies workspace verification as a deterministic contract: Fusion codifies workspace verification as a deterministic contract:

View File

@@ -6,8 +6,10 @@ import {
getPrebuildCommand, getPrebuildCommand,
normalizePrebuildMode, normalizePrebuildMode,
parseDevWrapperArgs, parseDevWrapperArgs,
resolveDevTunnelPort,
resolvePrebuildMode, resolvePrebuildMode,
} from "../../../../scripts/dev-with-memory-lib.mjs"; } from "../../../../scripts/dev-with-memory-lib.mjs";
import { extractQuickTunnelUrl } from "../../../../scripts/lib/dev-tunnel.mjs";
import { import {
createDevSourceWatcher, createDevSourceWatcher,
isRestartableSourceFile, isRestartableSourceFile,
@@ -55,6 +57,8 @@ describe("dev-with-memory prebuild options", () => {
requestedPrebuild: "none", requestedPrebuild: "none",
watchSource: false, watchSource: false,
watchSourceFromFlag: false, watchSourceFromFlag: false,
tunnel: false,
tunnelPort: undefined,
}); });
}); });
@@ -65,6 +69,8 @@ describe("dev-with-memory prebuild options", () => {
requestedPrebuild: "auto", requestedPrebuild: "auto",
watchSource: true, watchSource: true,
watchSourceFromFlag: true, watchSourceFromFlag: true,
tunnel: false,
tunnelPort: undefined,
}); });
}); });
@@ -320,4 +326,50 @@ describe("development source restart watcher", () => {
expect(closes[1]).toHaveBeenCalledOnce(); expect(closes[1]).toHaveBeenCalledOnce();
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("close failed")); 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();
});
});
}); });

View File

@@ -126,6 +126,14 @@ export function parseDevWrapperArgs(rawArgs, env = process.env) {
let requestedPrebuild = env.FUSION_DEV_PREBUILD ?? "auto"; let requestedPrebuild = env.FUSION_DEV_PREBUILD ?? "auto";
let watchSource = env.FUSION_DEV_WATCH === "1"; let watchSource = env.FUSION_DEV_WATCH === "1";
let watchSourceFromFlag = false; 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) { for (let i = 0; i < rawArgs.length; i += 1) {
const arg = rawArgs[i]; const arg = rawArgs[i];
@@ -160,6 +168,28 @@ export function parseDevWrapperArgs(rawArgs, env = process.env) {
continue; 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); args.push(arg);
} }
@@ -169,9 +199,24 @@ export function parseDevWrapperArgs(rawArgs, env = process.env) {
requestedPrebuild: normalizePrebuildMode(requestedPrebuild), requestedPrebuild: normalizePrebuildMode(requestedPrebuild),
watchSource, watchSource,
watchSourceFromFlag, 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) { export function resolvePrebuildMode(requestedPrebuild, forwardedArgs) {
const mode = normalizePrebuildMode(requestedPrebuild); const mode = normalizePrebuildMode(requestedPrebuild);
if (mode !== "auto") { if (mode !== "auto") {

View File

@@ -14,9 +14,11 @@ import {
createDevWatchRestartCoordinator, createDevWatchRestartCoordinator,
getPrebuildCommand, getPrebuildCommand,
parseDevWrapperArgs, parseDevWrapperArgs,
resolveDevTunnelPort,
resolvePrebuildMode, resolvePrebuildMode,
} from "./dev-with-memory-lib.mjs"; } from "./dev-with-memory-lib.mjs";
import { createDevSourceWatcher } from "./lib/dev-source-watch.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 // Set increased heap size (8GB) to prevent OOM during initial build/start
const MEMORY_MB = process.env.FUSION_DEV_MEMORY_MB || "8192"; const MEMORY_MB = process.env.FUSION_DEV_MEMORY_MB || "8192";
@@ -31,7 +33,7 @@ try {
console.error(error instanceof Error ? error.message : String(error)); console.error(error instanceof Error ? error.message : String(error));
process.exit(1); process.exit(1);
} }
const { inspectFlags, args, requestedPrebuild, watchSourceFromFlag } = parsedArgs; const { inspectFlags, args, requestedPrebuild, watchSourceFromFlag, tunnel, tunnelPort } = parsedArgs;
let { watchSource } = parsedArgs; let { watchSource } = parsedArgs;
// NODE_OPTIONS is shared with every spawned node process (build + run + // 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; const RESTART_EXIT_CODE = 86;
let appChild; let appChild;
let devTunnel;
let sourceWatcher; let sourceWatcher;
const watchRestart = createDevWatchRestartCoordinator(); const watchRestart = createDevWatchRestartCoordinator();
@@ -114,6 +117,23 @@ function runApp(extraArgs) {
}, },
}); });
appChild = tsx; 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); watchRestart.attach(tsx);
tsx.on("message", (message) => watchRestart.onMessage(message)); tsx.on("message", (message) => watchRestart.onMessage(message));
ensureSourceWatcher(); ensureSourceWatcher();
@@ -129,6 +149,7 @@ function runApp(extraArgs) {
} }
return; return;
} }
devTunnel?.stop?.();
process.exit(c ?? 1); process.exit(c ?? 1);
}); });
} }

110
scripts/lib/dev-tunnel.mjs Normal file
View File

@@ -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 };
}