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:
7
.changeset/feat-dev-tunnel.md
Normal file
7
.changeset/feat-dev-tunnel.md
Normal 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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
110
scripts/lib/dev-tunnel.mjs
Normal file
110
scripts/lib/dev-tunnel.mjs
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user