fix: tunnel the dev server's real port, not whatever holds 4040
The tunnel target was resolved from PORT/4040 before anything bound, but an occupied port makes the dashboard silently rebind to an ephemeral one (server.listen(0) on EADDRINUSE). With a normal Fusion already running on 4040, `pnpm dev --tunnel` therefore published THAT instance under a dev-looking URL while the dev server sat unreachable on a random port. The dashboard now reports the port it actually bound to the dev supervisor over IPC, and the wrapper tunnels that: - IPC is enabled whenever --tunnel is set, not only in watch mode; a plain tunnel run previously had no channel at all. - The tunnel waits for the report (60s cap, then falls back to the configured port with a warning), so it also cannot come up against a port nothing is serving yet. - A mismatch is logged instead of silent. - A reported port is treated as the dashboard whatever its number, so the banner keeps printing the bearer token; without that, the ephemeral case would classify the dev dashboard as a foreign port and drop it. - An explicit --tunnel=PORT names a target the dev child knows nothing about, so it never waits and is still compared against the configured port. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/dev-tunnel-targets-bound-port.md
Normal file
7
.changeset/dev-tunnel-targets-bound-port.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: `pnpm dev --tunnel` now tunnels the dev server's real port instead of another instance on 4040.
|
||||||
|
category: fix
|
||||||
|
dev: The tunnel target came from `PORT`/4040, resolved before anything bound. When that port was occupied the dashboard silently rebound to an ephemeral port (`server.listen(0)` on EADDRINUSE), so with a normal Fusion already on 4040 the tunnel published that instance under a dev-looking URL. The dashboard now reports its bound port to the dev supervisor over IPC (`DEV_SERVER_LISTENING_MESSAGE`, a no-op without an IPC channel), the wrapper enables IPC whenever `--tunnel` is set rather than only in watch mode, and the tunnel waits for that report (60s cap, falling back to the configured port with a warning) before starting — which also stops it coming up against a port nothing serves yet. A reported port is treated as the dashboard whatever its number, so the banner still prints the bearer token; an explicit `--tunnel=PORT` never waits and is still compared against the configured dashboard port.
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getPrebuildCommand,
|
getPrebuildCommand,
|
||||||
normalizePrebuildMode,
|
normalizePrebuildMode,
|
||||||
parseDevWrapperArgs,
|
parseDevWrapperArgs,
|
||||||
|
readDevServerListeningPort,
|
||||||
resolveDevTunnelPort,
|
resolveDevTunnelPort,
|
||||||
resolvePrebuildMode,
|
resolvePrebuildMode,
|
||||||
} from "../../../../scripts/dev-with-memory-lib.mjs";
|
} from "../../../../scripts/dev-with-memory-lib.mjs";
|
||||||
@@ -409,6 +410,30 @@ describe("development source restart watcher", () => {
|
|||||||
expect(auth()).toEqual({ kind: "token-pending" });
|
expect(auth()).toEqual({ kind: "token-pending" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:DevTunnel 2026-08-19-02:05:
|
||||||
|
The port the dev server is ASKED for is not the port it gets: an occupied port makes the
|
||||||
|
dashboard rebind to an ephemeral one. With a normal Fusion already on 4040 the tunnel therefore
|
||||||
|
pointed at THAT instance and served the wrong app under a dev-looking URL. The child's listening
|
||||||
|
report is the only fact about where the dev server actually is.
|
||||||
|
*/
|
||||||
|
it("reads the dev server's bound port from its listening report only", () => {
|
||||||
|
expect(readDevServerListeningPort({ type: "fusion:dev-server-listening", port: 51234 })).toBe(51234);
|
||||||
|
expect(readDevServerListeningPort({ type: "fusion:dev-source-restart-armed" })).toBeNull();
|
||||||
|
expect(readDevServerListeningPort({ type: "fusion:dev-server-listening" })).toBeNull();
|
||||||
|
expect(readDevServerListeningPort({ type: "fusion:dev-server-listening", port: 0 })).toBeNull();
|
||||||
|
expect(readDevServerListeningPort({ type: "fusion:dev-server-listening", port: "51234" })).toBe(51234);
|
||||||
|
expect(readDevServerListeningPort(null)).toBeNull();
|
||||||
|
expect(readDevServerListeningPort("fusion:dev-server-listening")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still treats a dev dashboard on an ephemeral port as token-gated", () => {
|
||||||
|
// The reported port IS the dashboard, so the banner must keep lending it the token rather
|
||||||
|
// than classifying it "foreign" for not matching 4040.
|
||||||
|
expect(auth({ port: 51234, dashboardPort: 51234, readToken: () => "fn_abc" }))
|
||||||
|
.toEqual({ kind: "token", token: "fn_abc" });
|
||||||
|
});
|
||||||
|
|
||||||
it("prints an openable URL for the token case and a warning only where it is true", () => {
|
it("prints an openable URL for the token case and a warning only where it is true", () => {
|
||||||
const url = "https://neat-fox-tree.trycloudflare.com";
|
const url = "https://neat-fox-tree.trycloudflare.com";
|
||||||
const tokenLines = formatDevTunnelBanner({ url, port: 4040, auth: { kind: "token", token: "fn_abc" } }).join("\n");
|
const tokenLines = formatDevTunnelBanner({ url, port: 4040, auth: { kind: "token", token: "fn_abc" } }).join("\n");
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type G
|
|||||||
import { DASHBOARD_STARTUP_STATUS, runTuiStartupPrelude } from "./dashboard-startup-chain.js";
|
import { DASHBOARD_STARTUP_STATUS, runTuiStartupPrelude } from "./dashboard-startup-chain.js";
|
||||||
import { phaseTime } from "../startup-phase.js";
|
import { phaseTime } from "../startup-phase.js";
|
||||||
import {
|
import {
|
||||||
|
DEV_SERVER_LISTENING_MESSAGE,
|
||||||
DEV_SOURCE_RESTART_ARMED_MESSAGE,
|
DEV_SOURCE_RESTART_ARMED_MESSAGE,
|
||||||
registerDevSourceRestart,
|
registerDevSourceRestart,
|
||||||
} from "./dev-source-restart.js";
|
} from "./dev-source-restart.js";
|
||||||
@@ -2963,6 +2964,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
logSink.warn(`Port ${selectedPort} in use, using ${actualPort} instead`, "dashboard");
|
logSink.warn(`Port ${selectedPort} in use, using ${actualPort} instead`, "dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FNXC:DevTunnel 2026-08-19-02:05: report the REAL port to the dev supervisor (no-op without an
|
||||||
|
// IPC channel, i.e. every non-`pnpm dev` launch). See DEV_SERVER_LISTENING_MESSAGE.
|
||||||
|
process.send?.({ type: DEV_SERVER_LISTENING_MESSAGE, port: actualPort, host: selectedHost });
|
||||||
|
|
||||||
// ── mDNS discovery: broadcast presence and listen for other nodes ───────
|
// ── mDNS discovery: broadcast presence and listen for other nodes ───────
|
||||||
//
|
//
|
||||||
// Advertises this node on the local network and discovers other Fusion nodes
|
// Advertises this node on the local network and discovers other Fusion nodes
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import type { EventEmitter } from "node:events";
|
|||||||
export const DEV_SOURCE_CHANGE_MESSAGE = "fusion:dev-source-changed";
|
export const DEV_SOURCE_CHANGE_MESSAGE = "fusion:dev-source-changed";
|
||||||
export const DEV_SOURCE_RESTART_ARMED_MESSAGE = "fusion:dev-source-restart-armed";
|
export const DEV_SOURCE_RESTART_ARMED_MESSAGE = "fusion:dev-source-restart-armed";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:DevTunnel 2026-08-19-02:05:
|
||||||
|
* The dev supervisor cannot know which port the dashboard ended up on: an occupied port makes the
|
||||||
|
* server silently rebind to an ephemeral one (`listen(0)`), so the port the supervisor ASKED for is
|
||||||
|
* a guess, not a fact. The child reports the bound port over the existing IPC channel so
|
||||||
|
* `--tunnel` targets the dev server rather than whatever else holds the configured port — which,
|
||||||
|
* when a normal Fusion is already running on 4040, was the other instance entirely.
|
||||||
|
*/
|
||||||
|
export const DEV_SERVER_LISTENING_MESSAGE = "fusion:dev-server-listening";
|
||||||
|
|
||||||
interface DevSourceChangeMessage {
|
interface DevSourceChangeMessage {
|
||||||
type: typeof DEV_SOURCE_CHANGE_MESSAGE;
|
type: typeof DEV_SOURCE_CHANGE_MESSAGE;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,12 +204,32 @@ export function parseDevWrapperArgs(rawArgs, env = process.env) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:DevTunnel 2026-08-19-02:05:
|
||||||
|
Mirrors DEV_SERVER_LISTENING_MESSAGE in packages/cli/src/commands/dev-source-restart.ts. The literal
|
||||||
|
is duplicated rather than imported because this wrapper is plain JS that must not load the TS build.
|
||||||
|
*/
|
||||||
|
export const DEV_SERVER_LISTENING_MESSAGE = "fusion:dev-server-listening";
|
||||||
|
|
||||||
|
/** Port from a dev child's listening report, or null for any other message. */
|
||||||
|
export function readDevServerListeningPort(message) {
|
||||||
|
if (!message || typeof message !== "object") return null;
|
||||||
|
if (message.type !== DEV_SERVER_LISTENING_MESSAGE) return null;
|
||||||
|
const port = Number(message.port);
|
||||||
|
return Number.isInteger(port) && port > 0 ? port : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Port the tunnel should point at.
|
* Port the tunnel should point at.
|
||||||
*
|
*
|
||||||
* FNXC:DevTunnel 2026-08-18-23:40: defaults to the dashboard's port, because `pnpm dev` with no
|
* 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
|
* target starts the dashboard. An explicit `--tunnel=PORT` wins so a Vite dev server (or anything
|
||||||
* else the operator started) can be exposed instead.
|
* else the operator started) can be exposed instead.
|
||||||
|
*
|
||||||
|
* FNXC:DevTunnel 2026-08-19-02:05: this is the port the dev server is ASKED for, which is only a
|
||||||
|
* guess — an occupied port makes it rebind to an ephemeral one. Without an explicit --tunnel=PORT
|
||||||
|
* the caller must prefer the port the child reports over this value; see
|
||||||
|
* readDevServerListeningPort.
|
||||||
*/
|
*/
|
||||||
export function resolveDevTunnelPort(tunnelPort, env = process.env) {
|
export function resolveDevTunnelPort(tunnelPort, env = process.env) {
|
||||||
if (tunnelPort) return tunnelPort;
|
if (tunnelPort) return tunnelPort;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
createDevWatchRestartCoordinator,
|
createDevWatchRestartCoordinator,
|
||||||
getPrebuildCommand,
|
getPrebuildCommand,
|
||||||
parseDevWrapperArgs,
|
parseDevWrapperArgs,
|
||||||
|
readDevServerListeningPort,
|
||||||
resolveDevTunnelPort,
|
resolveDevTunnelPort,
|
||||||
resolvePrebuildMode,
|
resolvePrebuildMode,
|
||||||
} from "./dev-with-memory-lib.mjs";
|
} from "./dev-with-memory-lib.mjs";
|
||||||
@@ -97,6 +98,59 @@ function ensureSourceWatcher() {
|
|||||||
console.log(`[fusion:dev] source watch active (${sourceWatcher.watchedPaths.join(", ")})`);
|
console.log(`[fusion:dev] source watch active (${sourceWatcher.watchedPaths.join(", ")})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:DevTunnel 2026-08-19-02:05:
|
||||||
|
Which port to tunnel is NOT knowable up front. `resolveDevTunnelPort` returns the port the dev
|
||||||
|
server is asked for, but an occupied port makes the dashboard rebind to an ephemeral one — so with a
|
||||||
|
normal Fusion already holding 4040, the tunnel pointed at THAT instance and served the wrong app
|
||||||
|
under a dev-looking URL. Wait for the child's listening report and tunnel the port it actually got.
|
||||||
|
|
||||||
|
An explicit `--tunnel=PORT` names a target the operator chose (a Vite server the dev child knows
|
||||||
|
nothing about), so it is used immediately and never waits. If the report never arrives the tunnel
|
||||||
|
still comes up on the configured port, since a mis-targeted preview beats no preview at all.
|
||||||
|
*/
|
||||||
|
const DEV_SERVER_PORT_REPORT_TIMEOUT_MS = 60_000;
|
||||||
|
let reportDevServerPort;
|
||||||
|
const devServerPortReport = new Promise((resolve) => { reportDevServerPort = resolve; });
|
||||||
|
|
||||||
|
async function resolveTunnelTargetPort() {
|
||||||
|
if (tunnelPort) return { port: tunnelPort, source: "explicit" };
|
||||||
|
|
||||||
|
const configured = resolveDevTunnelPort(undefined);
|
||||||
|
const timeout = new Promise((resolve) => {
|
||||||
|
setTimeout(() => resolve(null), DEV_SERVER_PORT_REPORT_TIMEOUT_MS).unref?.();
|
||||||
|
});
|
||||||
|
const reported = await Promise.race([devServerPortReport, timeout]);
|
||||||
|
|
||||||
|
if (reported == null) {
|
||||||
|
console.warn(`[fusion:dev] dev server never reported its port — tunnelling ${configured}, which may not be it`);
|
||||||
|
return { port: configured, source: "assumed" };
|
||||||
|
}
|
||||||
|
if (reported !== configured) {
|
||||||
|
console.log(`[fusion:dev] dev server bound ${reported} (not ${configured}) — tunnelling ${reported}`);
|
||||||
|
}
|
||||||
|
return { port: reported, source: "reported" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDevTunnel() {
|
||||||
|
const { port, source } = await resolveTunnelTargetPort();
|
||||||
|
/*
|
||||||
|
FNXC:DevTunnel 2026-08-19-02:05:
|
||||||
|
A port the CHILD reported is the dev dashboard by definition, whatever number it landed on — so it
|
||||||
|
is its own dashboardPort. Treating it as "some other port" would drop the token from the banner
|
||||||
|
precisely in the ephemeral-rebind case this fix exists for. An explicit --tunnel=PORT names an
|
||||||
|
arbitrary target, so that one is still compared against the configured dashboard port.
|
||||||
|
*/
|
||||||
|
const dashboardPort = source === "explicit" ? resolveDevTunnelPort(undefined) : port;
|
||||||
|
/*
|
||||||
|
FNXC:DevTunnel 2026-08-19-01:18:
|
||||||
|
Resolved at print time (not at parse time) so the token the dev child mints on a first
|
||||||
|
authenticated run is already on disk by the time the banner needs it.
|
||||||
|
*/
|
||||||
|
const auth = resolveDevTunnelAuth({ port, dashboardPort, args: forwardedArgs });
|
||||||
|
devTunnel = await startDevTunnel({ port, auth });
|
||||||
|
}
|
||||||
|
|
||||||
function runApp(extraArgs) {
|
function runApp(extraArgs) {
|
||||||
const tsx = spawn(process.execPath, buildDevNodeArgs({
|
const tsx = spawn(process.execPath, buildDevNodeArgs({
|
||||||
inspectFlags,
|
inspectFlags,
|
||||||
@@ -105,7 +159,9 @@ function runApp(extraArgs) {
|
|||||||
entry: ENTRY,
|
entry: ENTRY,
|
||||||
args: extraArgs,
|
args: extraArgs,
|
||||||
}), {
|
}), {
|
||||||
stdio: watchSource ? ["inherit", "inherit", "inherit", "ipc"] : "inherit",
|
// FNXC:DevTunnel 2026-08-19-02:05: the tunnel needs the child's IPC channel too, to learn the
|
||||||
|
// port it actually bound — not only watch mode.
|
||||||
|
stdio: (watchSource || tunnel) ? ["inherit", "inherit", "inherit", "ipc"] : "inherit",
|
||||||
// FNXC:SystemPanel 2026-07-25-10:05: stamp the supervisor pid alongside the
|
// FNXC:SystemPanel 2026-07-25-10:05: stamp the supervisor pid alongside the
|
||||||
// flag so the child can tell a real supervising parent from an inherited
|
// flag so the child can tell a real supervising parent from an inherited
|
||||||
// copy of the variable (see hasLiveSupervisingParent in commands/dashboard.ts).
|
// copy of the variable (see hasLiveSupervisingParent in commands/dashboard.ts).
|
||||||
@@ -126,26 +182,17 @@ function runApp(extraArgs) {
|
|||||||
unchanged and a fresh quick tunnel would hand out a different hostname every reload.
|
unchanged and a fresh quick tunnel would hand out a different hostname every reload.
|
||||||
*/
|
*/
|
||||||
if (tunnel && !devTunnel) {
|
if (tunnel && !devTunnel) {
|
||||||
const port = resolveDevTunnelPort(tunnelPort);
|
|
||||||
/*
|
|
||||||
FNXC:DevTunnel 2026-08-19-01:18:
|
|
||||||
Resolved at print time (not at parse time) so the token the dev child mints on a first
|
|
||||||
authenticated run is already on disk by the time the banner needs it.
|
|
||||||
*/
|
|
||||||
const auth = resolveDevTunnelAuth({
|
|
||||||
port,
|
|
||||||
dashboardPort: resolveDevTunnelPort(undefined),
|
|
||||||
args: forwardedArgs,
|
|
||||||
});
|
|
||||||
devTunnel = { url: null, stop: () => {} };
|
devTunnel = { url: null, stop: () => {} };
|
||||||
void startDevTunnel({ port, auth })
|
void openDevTunnel().catch((error) => {
|
||||||
.then((started) => { devTunnel = started; })
|
console.error(`[fusion:dev] tunnel error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
.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) => {
|
||||||
|
const listeningPort = readDevServerListeningPort(message);
|
||||||
|
if (listeningPort) reportDevServerPort(listeningPort);
|
||||||
|
watchRestart.onMessage(message);
|
||||||
|
});
|
||||||
ensureSourceWatcher();
|
ensureSourceWatcher();
|
||||||
tsx.on("close", (c) => {
|
tsx.on("close", (c) => {
|
||||||
const sourceRestart = watchRestart.detach(tsx);
|
const sourceRestart = watchRestart.detach(tsx);
|
||||||
|
|||||||
Reference in New Issue
Block a user