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
|
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:
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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
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