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>
111 lines
3.5 KiB
JavaScript
111 lines
3.5 KiB
JavaScript
/*
|
|
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 };
|
|
}
|