feat(cli): support AO_PUBLIC_URL for reverse-proxied dashboards (#1757)
* feat(cli): support AO_PUBLIC_URL for reverse-proxied dashboards When AO runs inside a remote dev container or behind a reverse proxy (Caddy/nginx/Traefik), `http://localhost:${port}` was hardcoded across the CLI for console output, `ao open` browser launches, and the session URLs surfaced to the orchestrator agent. None of those URLs were reachable from outside the host. Add an `AO_PUBLIC_URL` env var. When set, the new `dashboardUrl(port)` helper returns it (with trailing slashes stripped) instead of the localhost fallback. The helper replaces every user-facing `http://localhost:${port}` literal in: - `commands/dashboard.ts` — startup banner + browser open - `commands/start.ts` — 12 spots: spinner, "Dashboard:" prints, orchestrator URL fallback, `openUrl()` calls, and the running-state reuse paths - `lib/routes.ts` — `projectSessionUrl()` (used in the orchestrator prompt template, so worker links land on the public hostname) Internal IPC (`lib/daemon.ts` calling its own dashboard's `/api/projects/reload`) is intentionally left on localhost — that traffic never leaves the host, and routing it through a public URL would just add latency and a failure surface. Tests cover the env-var/localhost paths, whitespace trimming, trailing-slash stripping, sub-path preservation, and non-default-port URLs (`__tests__/lib/dashboard-url.test.ts`, 10 cases). Setup guide gets a new "Public dashboard URL" entry under optional env vars. * docs: cover TERMINAL_WS_PATH + path-based mux routing in AO_PUBLIC_URL setup The AO_PUBLIC_URL entry only mentioned terminal ports needing to be reachable, which over-specifies what's required when fronting AO with HTTPS through a reverse proxy. The dashboard's MuxProvider already auto-detects standard ports (`loc.port === ""`/`"443"`/`"80"`) and routes the mux WebSocket through `/ao-terminal-mux` on the same hostname, so a single proxy rule pointing at the dashboard port is sufficient — no extra subdomain or port forwarding for the WS. For non-standard ports or custom paths, document the existing but previously-undiscoverable `TERMINAL_WS_PATH` env var (read by `/api/runtime/terminal/route.ts` and threaded through `MuxProvider` as `proxyWsPath`). Adds a minimal Caddy snippet so users have a working starting point. * feat(web): accept /ao-terminal-mux as alias for /mux on direct-terminal-ws The dashboard's MuxProvider already constructs `wss://hostname/ao-terminal-mux` when accessed on a standard HTTPS port (443), but until now nothing on the server side recognized that path — direct-terminal-ws only matched `/mux`, and the Next.js dashboard doesn't handle WS upgrades at all. Deployments fronted by a path-routing reverse proxy (cloudflared, nginx, Caddy, …) hit the server at `/ao-terminal-mux`, fall through to Next.js, get a 404, and the dashboard's terminal panes hang at "Connecting…" forever. Fix is one line in the upgrade-routing allow-list: accept `/ao-terminal-mux` in addition to `/mux`. The proxy can now route the path-based mux URL straight at DIRECT_TERMINAL_PORT without needing a path-rewrite rule (which most proxies — including cloudflared — don't natively support). Existing `/mux` clients continue to work; the alias is strictly additive. SETUP.md's AO_PUBLIC_URL section is updated to mention the path requirement in one sentence, and a new integration test pins the behavior. * feat(web): opt-in single-port mode (AO_PATH_BASED_MUX) for proxy-only deployments Default behavior unchanged. When AO_PATH_BASED_MUX=1, start-all spawns a small bundled HTTP/WS proxy on PORT that demultiplexes: - HTTP requests forwarded to Next.js (shifted to PORT + 1000; override with NEXT_INTERNAL_PORT) - `wss://hostname/ao-terminal-mux` upgrades tunneled to DIRECT_TERMINAL_PORT/mux Use it when the reverse proxy in front of AO can only forward one hostname:port pair upstream (e.g. Cloudflare Tunnel pointed at a single `service:` URL with no path-based ingress, or a managed-app platform where you don't control the proxy config). One proxy rule then suffices — the WS path is multiplexed onto the same TCP port and demuxed inside the AO process. Tradeoff: one extra Node process and one extra hop per HTTP request, in exchange for proxy-config simplicity. For deployments that *can* do path-based routing the alias added in the previous commit (direct-terminal-ws accepting `/ao-terminal-mux` on its own port) is the lower-overhead path. The new server is pure Node http; no `next` import or other extra dependencies. It's strictly opt-in — the env-var gate keeps the code inert by default, so existing deployments see no behavior change and no extra startup cost. * fix(web): correct single-port proxy header handling, WS hangs, shutdown Addresses review feedback on single-port-server.ts: - Strip hop-by-hop headers (RFC 9110 §7.6.1) before forwarding upstream, including any extras named in the client Connection header. Previously the whole header set was copied verbatim, so a client Connection: close could tear down the keep-alive socket to Next.js. - Add X-Forwarded-For/-Proto/-Host so the upstream sees the real client instead of 127.0.0.1; existing values from an outer proxy are preserved. - Handle non-101 upstream responses on the WS upgrade path. The proxy only listened for 'upgrade', so a 404/502/mid-restart response left the client socket hanging until TCP timeout. A 'response' handler now relays the status and closes the connection. - Call server.closeAllConnections() on shutdown. server.close() alone waits for keep-alive HTTP sockets and piped WS tunnels to drain on their own, which they never do, so shutdown always hit the 5s force-exit timer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(web): cover single-port proxy + fix response-direction headers Follow-up to the previous commit's review fixes, adding regression coverage so future changes can't silently break the proxy. - Refactor single-port-server.ts into an exported createSinglePortServer() factory (mirrors direct-terminal-ws.ts) with a thin isMainModule() entrypoint, so start-all.ts still spawns it as a script while tests can drive it in-process against fake upstreams. - Add single-port-server.integration.test.ts (5 tests, no tmux/Next.js needed — runs on CI/Windows): hop-by-hop strip + X-Forwarded-*, 502 on dead upstream, /ao-terminal-mux WS tunnel, non-101 upgrade relay, and prompt shutdown with a live WS connection. - The shutdown test caught that server.closeAllConnections() does NOT destroy sockets already handed off via the 'upgrade' event — track upgraded sockets explicitly and destroy them in shutdown(). - The header test caught the symmetric response-direction leak: the proxy forwarded the upstream's Connection/Keep-Alive to the client, overriding a client that asked for Connection: close. Strip hop-by-hop from upstream responses too via filterResponseHeaders(). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Priyanshu Choudhary <57816400+Priyanchew@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c8a0dcbf70
commit
ecdf0c73ec
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@aoagents/ao-cli": minor
|
||||
---
|
||||
|
||||
Add `AO_PUBLIC_URL` environment variable for users running AO behind a reverse proxy (remote dev containers, VPS deployments, internal tooling). When set, all user-facing dashboard URLs — `ao start` / `ao dashboard` console output, `ao open` browser launches, and `projectSessionUrl()` links surfaced to the orchestrator agent — use the public URL instead of `http://localhost:<port>`. Internal IPC (`daemon.ts` reload calls) still uses localhost since that traffic stays on the host.
|
||||
|
||||
Also documents the existing `TERMINAL_WS_PATH` env var and the dashboard's automatic path-based mux WebSocket routing for standard-port (HTTPS / HTTP) deployments — these together let users front AO with a single hostname and one reverse-proxy rule, no extra ports or subdomains.
|
||||
7
SETUP.md
7
SETUP.md
|
|
@ -59,6 +59,13 @@ Comprehensive guide to installing, configuring, and troubleshooting Agent Orches
|
|||
- Create incoming webhook: https://api.slack.com/messaging/webhooks
|
||||
- Set environment variable: `export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..."`
|
||||
|
||||
- **Public dashboard URL** - If running AO behind a reverse proxy (e.g. inside a remote dev container, on a VPS fronted by Caddy/nginx/Traefik)
|
||||
- Set `AO_PUBLIC_URL` to the externally-reachable URL of the dashboard
|
||||
- All console output, `ao open` browser launches, and orchestrator-prompt session links use this URL instead of `http://localhost:<port>`
|
||||
- Example: `export AO_PUBLIC_URL="https://ao.example.com"`
|
||||
- When the dashboard is served on a standard port (HTTPS 443 / HTTP 80) the dashboard JS connects the mux WebSocket to `/ao-terminal-mux` on the same hostname. Your proxy needs to forward that path to the direct terminal server (`DIRECT_TERMINAL_PORT`, default 14801) — its upgrade handler accepts both `/mux` and `/ao-terminal-mux`. For custom paths set `TERMINAL_WS_PATH=/your/path`.
|
||||
- **`AO_PATH_BASED_MUX=1`** (opt-in) — if your proxy can only forward one hostname:port pair (e.g. Cloudflare Tunnel pointed at a single `service:` URL with no path-based ingress), set this and `ao start` will run a small bundled HTTP/WS proxy on `PORT` that demultiplexes: HTTP forwards to Next.js (shifted to `PORT + 1000`, override with `NEXT_INTERNAL_PORT`), and `wss://hostname/ao-terminal-mux` is tunneled to `DIRECT_TERMINAL_PORT/mux`. Tradeoff: an extra Node process and one extra hop per HTTP request, in exchange for a one-line proxy config on the operator side.
|
||||
|
||||
## Installation
|
||||
|
||||
### Install via npm (recommended)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { dashboardUrl } from "../../src/lib/dashboard-url.js";
|
||||
|
||||
describe("dashboardUrl", () => {
|
||||
const original = process.env.AO_PUBLIC_URL;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.AO_PUBLIC_URL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (original === undefined) {
|
||||
delete process.env.AO_PUBLIC_URL;
|
||||
} else {
|
||||
process.env.AO_PUBLIC_URL = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to localhost when AO_PUBLIC_URL is unset", () => {
|
||||
expect(dashboardUrl(3000)).toBe("http://localhost:3000");
|
||||
});
|
||||
|
||||
it("falls back to localhost when AO_PUBLIC_URL is empty", () => {
|
||||
process.env.AO_PUBLIC_URL = "";
|
||||
expect(dashboardUrl(8094)).toBe("http://localhost:8094");
|
||||
});
|
||||
|
||||
it("falls back to localhost when AO_PUBLIC_URL is whitespace only", () => {
|
||||
process.env.AO_PUBLIC_URL = " ";
|
||||
expect(dashboardUrl(8094)).toBe("http://localhost:8094");
|
||||
});
|
||||
|
||||
it("uses AO_PUBLIC_URL when set", () => {
|
||||
process.env.AO_PUBLIC_URL = "https://ao.example.com";
|
||||
expect(dashboardUrl(3000)).toBe("https://ao.example.com");
|
||||
});
|
||||
|
||||
it("ignores the port argument when AO_PUBLIC_URL is set", () => {
|
||||
process.env.AO_PUBLIC_URL = "https://ao.example.com";
|
||||
expect(dashboardUrl(3000)).toBe("https://ao.example.com");
|
||||
expect(dashboardUrl(8094)).toBe("https://ao.example.com");
|
||||
});
|
||||
|
||||
it("strips a trailing slash from AO_PUBLIC_URL", () => {
|
||||
process.env.AO_PUBLIC_URL = "https://ao.example.com/";
|
||||
expect(dashboardUrl(3000)).toBe("https://ao.example.com");
|
||||
});
|
||||
|
||||
it("strips multiple trailing slashes from AO_PUBLIC_URL", () => {
|
||||
process.env.AO_PUBLIC_URL = "https://ao.example.com///";
|
||||
expect(dashboardUrl(3000)).toBe("https://ao.example.com");
|
||||
});
|
||||
|
||||
it("preserves a sub-path in AO_PUBLIC_URL", () => {
|
||||
process.env.AO_PUBLIC_URL = "https://example.com/ao";
|
||||
expect(dashboardUrl(3000)).toBe("https://example.com/ao");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace from AO_PUBLIC_URL", () => {
|
||||
process.env.AO_PUBLIC_URL = " https://ao.example.com ";
|
||||
expect(dashboardUrl(3000)).toBe("https://ao.example.com");
|
||||
});
|
||||
|
||||
it("supports a non-default port in AO_PUBLIC_URL", () => {
|
||||
process.env.AO_PUBLIC_URL = "http://192.168.1.5:9000";
|
||||
expect(dashboardUrl(3000)).toBe("http://192.168.1.5:9000");
|
||||
});
|
||||
});
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from "../lib/dashboard-rebuild.js";
|
||||
import { preflight } from "../lib/preflight.js";
|
||||
import { DEFAULT_PORT } from "../lib/constants.js";
|
||||
import { dashboardUrl } from "../lib/dashboard-url.js";
|
||||
|
||||
export function registerDashboard(program: Command): void {
|
||||
program
|
||||
|
|
@ -42,7 +43,7 @@ export function registerDashboard(program: Command): void {
|
|||
|
||||
const webDir = localWebDir;
|
||||
|
||||
console.log(chalk.bold(`Starting dashboard on http://localhost:${port}\n`));
|
||||
console.log(chalk.bold(`Starting dashboard on ${dashboardUrl(port)}\n`));
|
||||
|
||||
const env = await buildDashboardEnv(
|
||||
port,
|
||||
|
|
@ -90,7 +91,7 @@ export function registerDashboard(program: Command): void {
|
|||
|
||||
if (opts.open !== false) {
|
||||
openAbort = new AbortController();
|
||||
void waitForPortAndOpen(port, `http://localhost:${port}`, openAbort.signal);
|
||||
void waitForPortAndOpen(port, dashboardUrl(port), openAbort.signal);
|
||||
}
|
||||
|
||||
child.on("exit", (code) => {
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ import {
|
|||
type DetectedAgent,
|
||||
} from "../lib/detect-agent.js";
|
||||
import { detectDefaultBranch } from "../lib/git-utils.js";
|
||||
import { dashboardUrl } from "../lib/dashboard-url.js";
|
||||
import { promptConfirm, promptSelect, promptText } from "../lib/prompts.js";
|
||||
import { extractOwnerRepo, isValidRepoString } from "../lib/repo-utils.js";
|
||||
import {
|
||||
|
|
@ -936,7 +937,7 @@ async function runStartup(
|
|||
config.directTerminalPort,
|
||||
opts?.dev,
|
||||
);
|
||||
spinner.succeed(`Dashboard starting on http://localhost:${port}`);
|
||||
spinner.succeed(`Dashboard starting on ${dashboardUrl(port)}`);
|
||||
console.log(chalk.dim(" (Dashboard will be ready in a few seconds)\n"));
|
||||
}
|
||||
|
||||
|
|
@ -1188,7 +1189,7 @@ async function runStartup(
|
|||
console.log(chalk.bold.green("\n✓ Startup complete\n"));
|
||||
|
||||
if (opts?.dashboard !== false) {
|
||||
console.log(chalk.cyan("Dashboard:"), `http://localhost:${port}`);
|
||||
console.log(chalk.cyan("Dashboard:"), dashboardUrl(port));
|
||||
}
|
||||
|
||||
if (shouldStartLifecycle) {
|
||||
|
|
@ -1222,7 +1223,7 @@ async function runStartup(
|
|||
openAbort = new AbortController();
|
||||
const orchestratorUrl = selectedOrchestratorId
|
||||
? projectSessionUrl(port, projectId, selectedOrchestratorId)
|
||||
: `http://localhost:${port}`;
|
||||
: dashboardUrl(port);
|
||||
void waitForPortAndOpen(port, orchestratorUrl, openAbort.signal);
|
||||
}
|
||||
|
||||
|
|
@ -1417,10 +1418,10 @@ async function attachAndSpawnOrchestrator(opts: {
|
|||
}
|
||||
|
||||
if (isHumanCaller()) {
|
||||
console.log(chalk.dim(` Opening dashboard: http://localhost:${daemon.port}\n`));
|
||||
openUrl(`http://localhost:${daemon.port}`);
|
||||
console.log(chalk.dim(` Opening dashboard: ${dashboardUrl(daemon.port)}\n`));
|
||||
openUrl(dashboardUrl(daemon.port));
|
||||
} else {
|
||||
console.log(`Dashboard: http://localhost:${daemon.port}`);
|
||||
console.log(`Dashboard: ${dashboardUrl(daemon.port)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1502,7 +1503,7 @@ export function registerStart(program: Command): void {
|
|||
// exit. Project-id args fall through to attach+spawn so
|
||||
// automation can `ao start <id>` against a live daemon.
|
||||
console.log(`AO is already running.`);
|
||||
console.log(`Dashboard: http://localhost:${running.port}`);
|
||||
console.log(`Dashboard: ${dashboardUrl(running.port)}`);
|
||||
console.log(`PID: ${running.pid}`);
|
||||
console.log(`Projects: ${running.projects.join(", ")}`);
|
||||
console.log(`To restart: ao stop && ao start`);
|
||||
|
|
@ -1512,7 +1513,7 @@ export function registerStart(program: Command): void {
|
|||
|
||||
if (isHumanCaller() && !projectArg) {
|
||||
console.log(chalk.cyan(`\nℹ AO is already running.`));
|
||||
console.log(` Dashboard: ${chalk.cyan(`http://localhost:${running.port}`)}`);
|
||||
console.log(` Dashboard: ${chalk.cyan(dashboardUrl(running.port))}`);
|
||||
console.log(` PID: ${running.pid} | Up since: ${running.startedAt}`);
|
||||
console.log(` Projects: ${running.projects.join(", ")}\n`);
|
||||
|
||||
|
|
@ -1559,7 +1560,7 @@ export function registerStart(program: Command): void {
|
|||
);
|
||||
|
||||
if (choice === "open") {
|
||||
openUrl(`http://localhost:${running.port}`);
|
||||
openUrl(dashboardUrl(running.port));
|
||||
unlockStartup();
|
||||
process.exit(0);
|
||||
} else if (choice === "quit") {
|
||||
|
|
@ -1591,7 +1592,7 @@ export function registerStart(program: Command): void {
|
|||
),
|
||||
);
|
||||
}
|
||||
openUrl(`http://localhost:${running.port}`);
|
||||
openUrl(dashboardUrl(running.port));
|
||||
unlockStartup();
|
||||
process.exit(0);
|
||||
} else if (choice === "new") {
|
||||
|
|
@ -1677,9 +1678,9 @@ export function registerStart(program: Command): void {
|
|||
running.projects.includes(projectId)
|
||||
) {
|
||||
console.log(chalk.cyan(`\nℹ AO is already running.`));
|
||||
console.log(` Dashboard: ${chalk.cyan(`http://localhost:${running.port}`)}`);
|
||||
console.log(` Dashboard: ${chalk.cyan(dashboardUrl(running.port))}`);
|
||||
console.log(` Project "${projectId}" is already registered and running.\n`);
|
||||
openUrl(`http://localhost:${running.port}`);
|
||||
openUrl(dashboardUrl(running.port));
|
||||
unlockStartup();
|
||||
process.exit(0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Returns the user-facing base URL of the dashboard.
|
||||
*
|
||||
* When `AO_PUBLIC_URL` is set in the environment, AO is being fronted by a
|
||||
* reverse proxy (e.g. when running inside a remote dev container or behind
|
||||
* Caddy/nginx). All console output, `ao open` browser launches, and session
|
||||
* URLs surfaced to humans should use that public URL instead of localhost.
|
||||
*
|
||||
* The trailing slash is stripped for consistency so callers can append paths
|
||||
* without producing `//`.
|
||||
*
|
||||
* Internal IPC (the daemon hitting its own dashboard's API) is intentionally
|
||||
* **not** routed through this helper — those calls always use localhost since
|
||||
* they happen on the same host as the dashboard process.
|
||||
*
|
||||
* @param port - the local dashboard port; only used in the localhost fallback
|
||||
*/
|
||||
export function dashboardUrl(port: number): string {
|
||||
const publicUrl = process.env.AO_PUBLIC_URL?.trim();
|
||||
if (publicUrl) {
|
||||
return publicUrl.replace(/\/+$/, "");
|
||||
}
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { dashboardUrl } from "./dashboard-url.js";
|
||||
|
||||
export function projectSessionUrl(port: number, projectId: string, sessionId: string): string {
|
||||
return `http://localhost:${port}/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`;
|
||||
return `${dashboardUrl(port)}/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,6 +180,19 @@ describeWithTmux("WebSocket upgrade routing", () => {
|
|||
ws.close();
|
||||
});
|
||||
|
||||
it("accepts connections on /ao-terminal-mux (alias for /mux)", async () => {
|
||||
// The dashboard's MuxProvider uses this path on standard-port deployments
|
||||
// so a path-routing reverse proxy can forward it here without a rewrite.
|
||||
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
||||
const sock = new WebSocket(`ws://localhost:${port}/ao-terminal-mux`);
|
||||
sock.on("open", () => resolve(sock));
|
||||
sock.on("error", reject);
|
||||
setTimeout(() => reject(new Error("WebSocket connect timeout")), 5000);
|
||||
});
|
||||
expect(ws.readyState).toBe(WebSocket.OPEN);
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it("destroys connections on unknown paths", async () => {
|
||||
const result = await new Promise<{ code: number }>((resolve) => {
|
||||
const ws = new WebSocket(`ws://localhost:${port}/ws`);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,308 @@
|
|||
/**
|
||||
* Integration tests for single-port-server — the opt-in HTTP + WebSocket
|
||||
* proxy used when AO_PATH_BASED_MUX=1.
|
||||
*
|
||||
* These pin the four behaviours surfaced in review of PR #1757:
|
||||
* 1. Hop-by-hop request headers are stripped before reaching the upstream.
|
||||
* 2. X-Forwarded-For/-Proto/-Host are added so the upstream sees the client.
|
||||
* 3. A non-101 response on the WS upgrade path is relayed, not left to hang.
|
||||
* 4. shutdown() closes promptly even with a live connection open.
|
||||
*
|
||||
* The proxy is pointed at lightweight fake upstreams, so no tmux / Next.js is
|
||||
* needed — these run everywhere, including CI on Windows.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import {
|
||||
createServer as createHttpServer,
|
||||
request,
|
||||
type Server,
|
||||
type IncomingMessage,
|
||||
} from "node:http";
|
||||
import { connect as netConnect, type AddressInfo } from "node:net";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { createSinglePortServer, type SinglePortServer } from "../single-port-server.js";
|
||||
|
||||
// =============================================================================
|
||||
// Teardown registry — every server/proxy created by a test is closed here.
|
||||
// =============================================================================
|
||||
|
||||
const closers: Array<() => void | Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (closers.length > 0) {
|
||||
const close = closers.pop();
|
||||
if (close) await close();
|
||||
}
|
||||
});
|
||||
|
||||
function portOf(server: Server): number {
|
||||
const addr = server.address() as AddressInfo | null;
|
||||
return addr ? addr.port : 0;
|
||||
}
|
||||
|
||||
/** A fake "Next.js" upstream that echoes the request it received as JSON. */
|
||||
async function startEchoUpstream(): Promise<number> {
|
||||
const server = createHttpServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (c: Buffer) => (body += c.toString()));
|
||||
req.on("end", () => {
|
||||
// Set an explicit Content-Length (not chunked) so the raw-socket test
|
||||
// can read the body without decoding chunk framing.
|
||||
const payload = JSON.stringify({
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
headers: req.headers,
|
||||
body,
|
||||
});
|
||||
res.writeHead(200, {
|
||||
"content-type": "application/json",
|
||||
"content-length": Buffer.byteLength(payload),
|
||||
});
|
||||
res.end(payload);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
closers.push(() => new Promise<void>((r) => server.close(() => r())));
|
||||
return portOf(server);
|
||||
}
|
||||
|
||||
/** A fake direct-terminal-ws upstream: real WS server on /mux that echoes. */
|
||||
async function startWsUpstream(): Promise<number> {
|
||||
const server = createHttpServer();
|
||||
const wss = new WebSocketServer({ server, path: "/mux" });
|
||||
wss.on("connection", (socket) => {
|
||||
socket.on("message", (data) => socket.send(data));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
closers.push(() => {
|
||||
wss.close();
|
||||
return new Promise<void>((r) => server.close(() => r()));
|
||||
});
|
||||
return portOf(server);
|
||||
}
|
||||
|
||||
/**
|
||||
* A fake upstream that answers a WS upgrade with a plain non-101 response —
|
||||
* the case fix #3 exists for.
|
||||
*/
|
||||
async function startNon101Upstream(): Promise<number> {
|
||||
const server = createHttpServer();
|
||||
server.on("upgrade", (_req, socket) => {
|
||||
socket.end(
|
||||
"HTTP/1.1 503 Service Unavailable\r\n" +
|
||||
"content-type: text/plain\r\n" +
|
||||
"content-length: 11\r\n" +
|
||||
"connection: close\r\n\r\n" +
|
||||
"unavailable",
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
closers.push(() => new Promise<void>((r) => server.close(() => r())));
|
||||
return portOf(server);
|
||||
}
|
||||
|
||||
/** Start the proxy in front of the given upstream ports. */
|
||||
async function startProxy(opts: {
|
||||
nextInternalPort: number;
|
||||
directTerminalPort: number;
|
||||
}): Promise<{ proxy: SinglePortServer; port: number }> {
|
||||
const proxy = createSinglePortServer({
|
||||
port: 0,
|
||||
nextInternalPort: opts.nextInternalPort,
|
||||
directTerminalPort: opts.directTerminalPort,
|
||||
});
|
||||
await proxy.listen();
|
||||
closers.push(() => proxy.shutdown());
|
||||
return { proxy, port: portOf(proxy.server) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a raw HTTP request and collect the full raw response. `closedByPeer`
|
||||
* reports whether the server closed the connection on its own — used to prove
|
||||
* the proxy honoured the client's `Connection: close` instead of forwarding
|
||||
* the upstream's keep-alive.
|
||||
*/
|
||||
function rawHttpRequest(
|
||||
port: number,
|
||||
requestLines: string[],
|
||||
): Promise<{ raw: string; closedByPeer: boolean }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = netConnect({ port, host: "127.0.0.1" }, () => {
|
||||
socket.write(requestLines.join("\r\n") + "\r\n\r\n");
|
||||
});
|
||||
let raw = "";
|
||||
let closedByPeer = false;
|
||||
socket.setEncoding("utf8");
|
||||
socket.on("data", (chunk: string) => (raw += chunk));
|
||||
socket.on("error", reject);
|
||||
socket.on("end", () => {
|
||||
closedByPeer = true;
|
||||
resolve({ raw, closedByPeer });
|
||||
});
|
||||
setTimeout(() => {
|
||||
socket.destroy();
|
||||
resolve({ raw, closedByPeer });
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HTTP forwarding — header sanitisation
|
||||
// =============================================================================
|
||||
|
||||
describe("single-port HTTP forwarding", () => {
|
||||
it("strips hop-by-hop headers and adds X-Forwarded-* before reaching upstream", async () => {
|
||||
const nextPort = await startEchoUpstream();
|
||||
const { port } = await startProxy({ nextInternalPort: nextPort, directTerminalPort: 1 });
|
||||
|
||||
const { raw, closedByPeer } = await rawHttpRequest(port, [
|
||||
"GET /echo HTTP/1.1",
|
||||
"Host: dashboard.example",
|
||||
// "close" ends our client connection; "x-custom-hop" is named in
|
||||
// Connection so it is hop-by-hop too — both must be stripped.
|
||||
"Connection: close, X-Custom-Hop",
|
||||
"X-Custom-Hop: should-be-stripped",
|
||||
"Keep-Alive: timeout=99",
|
||||
"X-Forwarded-For: 1.2.3.4",
|
||||
]);
|
||||
|
||||
// The proxy must honour the client's Connection: close and not forward
|
||||
// the upstream's keep-alive — otherwise the connection lingers.
|
||||
expect(closedByPeer).toBe(true);
|
||||
|
||||
const body = raw.slice(raw.indexOf("\r\n\r\n") + 4);
|
||||
const seen = JSON.parse(body) as { headers: Record<string, string> };
|
||||
|
||||
// The client's hop-by-hop headers must not leak to the upstream.
|
||||
// X-Custom-Hop is listed in the client's Connection header, marking it
|
||||
// hop-by-hop — its absence proves Connection-token parsing works.
|
||||
expect(seen.headers["x-custom-hop"]).toBeUndefined();
|
||||
// The standard hop-by-hop Keep-Alive header is dropped.
|
||||
expect(seen.headers["keep-alive"]).not.toBe("timeout=99");
|
||||
// The client's Connection value ("close") must not propagate. The
|
||||
// proxy↔upstream hop has its own Connection header, managed by Node —
|
||||
// that is correct and expected, so only assert the client's value is gone.
|
||||
expect(String(seen.headers.connection ?? "")).not.toMatch(/close|x-custom-hop/);
|
||||
|
||||
// X-Forwarded-For preserves the prior value and appends the proxy client.
|
||||
expect(seen.headers["x-forwarded-for"]).toMatch(/^1\.2\.3\.4, /);
|
||||
expect(seen.headers["x-forwarded-proto"]).toBe("http");
|
||||
expect(seen.headers["x-forwarded-host"]).toBe("dashboard.example");
|
||||
});
|
||||
|
||||
it("returns 502 when the Next.js upstream is unreachable", async () => {
|
||||
// Point at a port nothing is listening on.
|
||||
const { port } = await startProxy({ nextInternalPort: 1, directTerminalPort: 1 });
|
||||
|
||||
const status = await new Promise<number>((resolve, reject) => {
|
||||
const req = request(
|
||||
{ host: "127.0.0.1", port, path: "/", method: "GET" },
|
||||
(res: IncomingMessage) => {
|
||||
res.resume();
|
||||
resolve(res.statusCode ?? 0);
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
|
||||
expect(status).toBe(502);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// WebSocket upgrade path
|
||||
// =============================================================================
|
||||
|
||||
describe("single-port WebSocket upgrade", () => {
|
||||
it("tunnels /ao-terminal-mux to the terminal upstream's /mux", async () => {
|
||||
const wsPort = await startWsUpstream();
|
||||
const { port } = await startProxy({ nextInternalPort: 1, directTerminalPort: wsPort });
|
||||
|
||||
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
||||
const sock = new WebSocket(`ws://127.0.0.1:${port}/ao-terminal-mux`);
|
||||
sock.on("open", () => resolve(sock));
|
||||
sock.on("error", reject);
|
||||
setTimeout(() => reject(new Error("WS connect timeout")), 3000);
|
||||
});
|
||||
|
||||
const echoed = await new Promise<string>((resolve, reject) => {
|
||||
ws.on("message", (data) => resolve(data.toString()));
|
||||
ws.on("error", reject);
|
||||
ws.send("ping-through-proxy");
|
||||
setTimeout(() => reject(new Error("WS echo timeout")), 3000);
|
||||
});
|
||||
|
||||
expect(echoed).toBe("ping-through-proxy");
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it("relays a non-101 upstream response instead of hanging the client", async () => {
|
||||
// Regression test for the WS-upgrade hang: before the `response` handler,
|
||||
// a non-101 upstream answer left the client socket open until TCP timeout.
|
||||
const badPort = await startNon101Upstream();
|
||||
const { port } = await startProxy({ nextInternalPort: 1, directTerminalPort: badPort });
|
||||
|
||||
const result = await new Promise<{ type: string; status?: number }>((resolve) => {
|
||||
const req = request({
|
||||
host: "127.0.0.1",
|
||||
port,
|
||||
path: "/ao-terminal-mux",
|
||||
headers: {
|
||||
Connection: "Upgrade",
|
||||
Upgrade: "websocket",
|
||||
"Sec-WebSocket-Version": "13",
|
||||
// Generated rather than hardcoded — a literal base64 nonce trips
|
||||
// the repo's gitleaks pre-commit hook as a high-entropy string.
|
||||
"Sec-WebSocket-Key": randomBytes(16).toString("base64"),
|
||||
},
|
||||
});
|
||||
req.on("upgrade", (res) => resolve({ type: "upgrade", status: res.statusCode }));
|
||||
req.on("response", (res) => {
|
||||
res.resume();
|
||||
resolve({ type: "response", status: res.statusCode });
|
||||
});
|
||||
req.on("error", () => resolve({ type: "error" }));
|
||||
// If the proxy hangs, none of the above fire and this surfaces it.
|
||||
setTimeout(() => resolve({ type: "hang" }), 3000);
|
||||
req.end();
|
||||
});
|
||||
|
||||
expect(result.type).toBe("response");
|
||||
expect(result.status).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Shutdown
|
||||
// =============================================================================
|
||||
|
||||
describe("single-port shutdown", () => {
|
||||
it("closes promptly with a live WebSocket connection open", async () => {
|
||||
// Regression test for shutdown hitting the force-exit timer: server.close()
|
||||
// alone waits forever for the piped WS tunnel; closeAllConnections() fixes it.
|
||||
const wsPort = await startWsUpstream();
|
||||
const { proxy, port } = await startProxy({
|
||||
nextInternalPort: 1,
|
||||
directTerminalPort: wsPort,
|
||||
});
|
||||
|
||||
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
||||
const sock = new WebSocket(`ws://127.0.0.1:${port}/ao-terminal-mux`);
|
||||
sock.on("open", () => resolve(sock));
|
||||
sock.on("error", reject);
|
||||
setTimeout(() => reject(new Error("WS connect timeout")), 3000);
|
||||
});
|
||||
|
||||
const start = Date.now();
|
||||
await proxy.shutdown();
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
// Comfortably under the 5s force-exit timer the entrypoint arms.
|
||||
expect(elapsed).toBeLessThan(1000);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
|
@ -61,10 +61,15 @@ export function createDirectTerminalServer(tmuxPath?: string | null): DirectTerm
|
|||
|
||||
// Manual upgrade routing — ws library doesn't support multiple WebSocketServer
|
||||
// instances with different `path` options on the same HTTP server.
|
||||
// `/ao-terminal-mux` is accepted as an alias of `/mux` so deployments fronted
|
||||
// by a path-routing reverse proxy (e.g. cloudflared, nginx) can forward the
|
||||
// dashboard's path-based mux URL straight at this port without needing a
|
||||
// path-rewrite rule. The dashboard's MuxProvider already constructs that
|
||||
// path when accessed on a standard HTTPS port; see `packages/web/src/providers/MuxProvider.tsx`.
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
const pathname = new URL(request.url ?? "/", "ws://localhost").pathname;
|
||||
|
||||
if (pathname === "/mux" && muxWss) {
|
||||
if ((pathname === "/mux" || pathname === "/ao-terminal-mux") && muxWss) {
|
||||
muxWss.handleUpgrade(request, socket, head, (ws) => {
|
||||
muxWss!.emit("connection", ws, request);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,378 @@
|
|||
/**
|
||||
* Single-port server (opt-in) — a thin HTTP + WebSocket proxy that puts
|
||||
* Next.js and the `/ao-terminal-mux` WebSocket upgrade on the same public
|
||||
* port. Spawned by start-all.ts when AO_PATH_BASED_MUX=1, in front of a
|
||||
* Next.js process that has shifted to an internal port.
|
||||
*
|
||||
* ┌──────────────────────┐ HTTP ┌──────────────────────┐
|
||||
* │ proxy on PORT │───────▶│ next start │
|
||||
* │ (this file) │ │ on NEXT_INTERNAL_PORT │
|
||||
* │ │ └──────────────────────┘
|
||||
* │ │ WS upgrade /ao-terminal-mux
|
||||
* │ │───────▶┌──────────────────────┐
|
||||
* │ │ │ direct-terminal-ws │
|
||||
* │ │ │ on DIRECT_TERMINAL │
|
||||
* │ │ └──────────────────────┘
|
||||
* └──────────────────────┘
|
||||
*
|
||||
* The default flow (AO_PATH_BASED_MUX unset) is unchanged: Next.js runs on
|
||||
* PORT directly, direct-terminal-ws runs on DIRECT_TERMINAL_PORT, and the
|
||||
* dashboard JS picks one of three URLs at connection time
|
||||
* (see `packages/web/src/providers/MuxProvider.tsx`):
|
||||
*
|
||||
* 1. proxyWsPath (TERMINAL_WS_PATH) — explicit path-based routing
|
||||
* 2. standard port (loc.port "" / 443 / 80) — `/ao-terminal-mux` on same host
|
||||
* 3. fallback — direct connection to `:DIRECT_TERMINAL_PORT/mux`
|
||||
*
|
||||
* Path #1 and #3 require the operator to do something at the proxy layer
|
||||
* (path rewrite or per-port routing). Path #2 only works if *something* is
|
||||
* listening for the `/ao-terminal-mux` upgrade on the dashboard port. Until
|
||||
* now, nothing was — Next.js doesn't handle upgrades, so the request fell
|
||||
* through to its 404 handler. This server is that something.
|
||||
*
|
||||
* Use this when the reverse proxy in front of AO can only forward one
|
||||
* hostname:port pair upstream (e.g. Cloudflare Tunnel pointed at one
|
||||
* `service:` URL with no path-based ingress). With this enabled, a single
|
||||
* proxy rule pointing at PORT is sufficient — the WS path is multiplexed
|
||||
* onto the same TCP port and demuxed here.
|
||||
*
|
||||
* `createSinglePortServer()` is exported so the proxy behaviour can be
|
||||
* exercised in tests; the bottom-of-file entrypoint wires it to env vars and
|
||||
* process signals when this file is run directly (as start-all.ts spawns it).
|
||||
*/
|
||||
|
||||
import {
|
||||
createServer,
|
||||
request as httpRequest,
|
||||
type IncomingHttpHeaders,
|
||||
type IncomingMessage,
|
||||
type OutgoingHttpHeaders,
|
||||
type Server,
|
||||
} from "node:http";
|
||||
import type { Socket } from "node:net";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const MUX_PATH = "/ao-terminal-mux";
|
||||
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Hop-by-hop headers (RFC 9110 §7.6.1) are meaningful only on a single
|
||||
* transport connection and must not be forwarded by an intermediary.
|
||||
* Forwarding e.g. a client's `Connection: close` would tear down the
|
||||
* keep-alive socket to the upstream; a stray `Transfer-Encoding` would
|
||||
* desync framing once the body is re-encoded.
|
||||
*/
|
||||
const HOP_BY_HOP = new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
]);
|
||||
|
||||
export interface SinglePortConfig {
|
||||
/** Public-facing port this proxy listens on. */
|
||||
port: number;
|
||||
/** Internal port Next.js has been shifted to. */
|
||||
nextInternalPort: number;
|
||||
/** Port direct-terminal-ws listens on. */
|
||||
directTerminalPort: number;
|
||||
}
|
||||
|
||||
export interface SinglePortServer {
|
||||
/** The underlying HTTP server — exposed for `.address()` in tests. */
|
||||
server: Server;
|
||||
/** Begin listening on the configured port; resolves once bound. */
|
||||
listen(): Promise<void>;
|
||||
/**
|
||||
* Stop listening and force-close every live connection, resolving once the
|
||||
* server is fully closed. `server.close()` alone waits for keep-alive HTTP
|
||||
* sockets and piped WS tunnels to drain on their own, which they never do.
|
||||
*/
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the header set for an upstream request: strip hop-by-hop headers
|
||||
* (including any extra ones named in the client's `Connection` header) and
|
||||
* append the standard `X-Forwarded-*` trio so the upstream still sees the
|
||||
* real client IP / proto / host instead of `127.0.0.1`.
|
||||
*
|
||||
* On the WebSocket upgrade path, `keepUpgrade` retains `Connection` and
|
||||
* `Upgrade` — the handshake is exactly the case where those headers are
|
||||
* load-bearing rather than hop-by-hop noise.
|
||||
*/
|
||||
function buildUpstreamHeaders(
|
||||
req: IncomingMessage,
|
||||
opts: { keepUpgrade: boolean },
|
||||
): OutgoingHttpHeaders {
|
||||
const drop = new Set(HOP_BY_HOP);
|
||||
|
||||
const connection = req.headers.connection;
|
||||
if (connection) {
|
||||
const tokens = Array.isArray(connection) ? connection : connection.split(",");
|
||||
for (const token of tokens) {
|
||||
const name = token.trim().toLowerCase();
|
||||
if (name) drop.add(name);
|
||||
}
|
||||
}
|
||||
if (opts.keepUpgrade) {
|
||||
drop.delete("connection");
|
||||
drop.delete("upgrade");
|
||||
}
|
||||
|
||||
const headers: OutgoingHttpHeaders = {};
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (value === undefined) continue;
|
||||
if (drop.has(key.toLowerCase())) continue;
|
||||
headers[key] = value;
|
||||
}
|
||||
|
||||
// X-Forwarded-*: preserve anything an outer proxy already set, then add ours.
|
||||
const clientIp = req.socket.remoteAddress ?? "";
|
||||
const priorFor = headers["x-forwarded-for"];
|
||||
headers["x-forwarded-for"] = priorFor
|
||||
? `${Array.isArray(priorFor) ? priorFor.join(", ") : String(priorFor)}, ${clientIp}`
|
||||
: clientIp;
|
||||
// This proxy itself terminates plain HTTP; an outer TLS proxy would have
|
||||
// set x-forwarded-proto already, so only fill it in when absent.
|
||||
if (headers["x-forwarded-proto"] === undefined) {
|
||||
headers["x-forwarded-proto"] = "http";
|
||||
}
|
||||
if (headers["x-forwarded-host"] === undefined && req.headers.host) {
|
||||
headers["x-forwarded-host"] = req.headers.host;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop hop-by-hop headers from an upstream *response* before relaying it to
|
||||
* the client. Without this the upstream's `Connection`/`Keep-Alive` would
|
||||
* override the proxy↔client connection's own semantics — e.g. forwarding the
|
||||
* upstream's `Connection: keep-alive` ignores a client that asked for `close`.
|
||||
* Framing headers (`transfer-encoding`) are dropped here too so the client's
|
||||
* `ServerResponse` re-derives framing for its own hop.
|
||||
*/
|
||||
function filterResponseHeaders(headers: IncomingHttpHeaders): OutgoingHttpHeaders {
|
||||
const out: OutgoingHttpHeaders = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (value === undefined) continue;
|
||||
if (HOP_BY_HOP.has(key.toLowerCase())) continue;
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function tunnelUpgrade(
|
||||
req: IncomingMessage,
|
||||
clientSocket: Socket,
|
||||
clientHead: Buffer,
|
||||
target: { host: string; port: number; path: string },
|
||||
): void {
|
||||
const proxyReq = httpRequest({
|
||||
host: target.host,
|
||||
port: target.port,
|
||||
method: "GET",
|
||||
path: target.path,
|
||||
headers: buildUpstreamHeaders(req, { keepUpgrade: true }),
|
||||
});
|
||||
|
||||
proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
|
||||
const lines = [
|
||||
`HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? "Switching Protocols"}`,
|
||||
];
|
||||
for (const [key, value] of Object.entries(proxyRes.headers)) {
|
||||
if (value === undefined) continue;
|
||||
lines.push(`${key}: ${Array.isArray(value) ? value.join(", ") : String(value)}`);
|
||||
}
|
||||
lines.push("\r\n");
|
||||
clientSocket.write(lines.join("\r\n"));
|
||||
|
||||
if (proxyHead.length > 0) clientSocket.write(proxyHead);
|
||||
if (clientHead.length > 0) proxySocket.write(clientHead);
|
||||
|
||||
clientSocket.pipe(proxySocket);
|
||||
proxySocket.pipe(clientSocket);
|
||||
|
||||
const teardown = (): void => {
|
||||
clientSocket.destroy();
|
||||
proxySocket.destroy();
|
||||
};
|
||||
proxySocket.on("error", teardown);
|
||||
proxySocket.on("close", teardown);
|
||||
clientSocket.on("error", teardown);
|
||||
clientSocket.on("close", teardown);
|
||||
});
|
||||
|
||||
// Upstream answered the upgrade with an ordinary response (404, 502,
|
||||
// mid-restart, path not in its allow-list, …) instead of a 101. Without
|
||||
// this handler the `upgrade` event never fires and the client socket
|
||||
// hangs until its TCP timeout. Relay the response and close cleanly.
|
||||
proxyReq.on("response", (proxyRes) => {
|
||||
if (clientSocket.writableEnded || clientSocket.destroyed) {
|
||||
proxyRes.destroy();
|
||||
return;
|
||||
}
|
||||
const lines = [`HTTP/1.1 ${proxyRes.statusCode ?? 502} ${proxyRes.statusMessage ?? ""}`];
|
||||
for (const [key, value] of Object.entries(proxyRes.headers)) {
|
||||
if (value === undefined) continue;
|
||||
const lower = key.toLowerCase();
|
||||
// Body is delimited by connection close below, so drop framing headers.
|
||||
if (HOP_BY_HOP.has(lower) || lower === "content-length") continue;
|
||||
lines.push(`${key}: ${Array.isArray(value) ? value.join(", ") : String(value)}`);
|
||||
}
|
||||
lines.push("connection: close");
|
||||
lines.push("\r\n");
|
||||
clientSocket.write(lines.join("\r\n"));
|
||||
proxyRes.pipe(clientSocket);
|
||||
proxyRes.on("end", () => clientSocket.end());
|
||||
});
|
||||
|
||||
proxyReq.on("error", (err) => {
|
||||
console.error(
|
||||
`[single-port] upstream upgrade error (${target.host}:${target.port}${target.path}): ${err.message}`,
|
||||
);
|
||||
clientSocket.destroy();
|
||||
});
|
||||
|
||||
proxyReq.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the single-port proxy. The returned server is not yet listening —
|
||||
* call `listen()`.
|
||||
*/
|
||||
export function createSinglePortServer(config: SinglePortConfig): SinglePortServer {
|
||||
const { port, nextInternalPort, directTerminalPort } = config;
|
||||
|
||||
// Sockets handed off via the 'upgrade' event are no longer tracked by the
|
||||
// HTTP server, so `server.closeAllConnections()` does not destroy them and
|
||||
// `server.close()`'s callback would wait on them forever. Track them here.
|
||||
const upgradedSockets = new Set<Socket>();
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const proxyReq = httpRequest(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port: nextInternalPort,
|
||||
method: req.method,
|
||||
path: req.url,
|
||||
headers: buildUpstreamHeaders(req, { keepUpgrade: false }),
|
||||
},
|
||||
(proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode ?? 502, filterResponseHeaders(proxyRes.headers));
|
||||
proxyRes.pipe(res);
|
||||
},
|
||||
);
|
||||
|
||||
proxyReq.on("error", (err) => {
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502, { "content-type": "text/plain" });
|
||||
}
|
||||
res.end(`Bad gateway: ${err.message}`);
|
||||
});
|
||||
|
||||
req.pipe(proxyReq);
|
||||
});
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
const clientSocket = socket as Socket;
|
||||
upgradedSockets.add(clientSocket);
|
||||
clientSocket.once("close", () => upgradedSockets.delete(clientSocket));
|
||||
|
||||
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
const target =
|
||||
pathname === MUX_PATH
|
||||
? { host: "127.0.0.1", port: directTerminalPort, path: "/mux" }
|
||||
: { host: "127.0.0.1", port: nextInternalPort, path: req.url ?? "/" };
|
||||
|
||||
tunnelUpgrade(req, clientSocket, head, target);
|
||||
});
|
||||
|
||||
return {
|
||||
server,
|
||||
listen() {
|
||||
return new Promise<void>((resolve) => server.listen(port, () => resolve()));
|
||||
},
|
||||
shutdown() {
|
||||
return new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
// closeAllConnections() handles keep-alive HTTP sockets; the upgraded
|
||||
// WS tunnels are tracked separately and destroyed here. Destroying the
|
||||
// client socket triggers tunnelUpgrade's teardown for the upstream side.
|
||||
server.closeAllConnections();
|
||||
for (const socket of upgradedSockets) socket.destroy();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse and validate the proxy config from env vars, exiting on bad input. */
|
||||
function configFromEnv(): SinglePortConfig {
|
||||
const port = parseInt(process.env.PORT ?? "3000", 10);
|
||||
const directTerminalPort = parseInt(process.env.DIRECT_TERMINAL_PORT ?? "14801", 10);
|
||||
const nextInternalPort = parseInt(process.env.NEXT_INTERNAL_PORT ?? "0", 10);
|
||||
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
||||
console.error(`[single-port] Invalid PORT: ${process.env.PORT}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(directTerminalPort) ||
|
||||
directTerminalPort < 1 ||
|
||||
directTerminalPort > 65_535
|
||||
) {
|
||||
console.error(
|
||||
`[single-port] Invalid DIRECT_TERMINAL_PORT: ${process.env.DIRECT_TERMINAL_PORT}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(nextInternalPort) ||
|
||||
nextInternalPort < 1 ||
|
||||
nextInternalPort > 65_535 ||
|
||||
nextInternalPort === port
|
||||
) {
|
||||
console.error(
|
||||
`[single-port] Invalid NEXT_INTERNAL_PORT (must differ from PORT): ${process.env.NEXT_INTERNAL_PORT}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return { port, nextInternalPort, directTerminalPort };
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const config = configFromEnv();
|
||||
const proxy = createSinglePortServer(config);
|
||||
|
||||
void proxy.listen().then(() => {
|
||||
console.log(
|
||||
`[single-port] listening on ${config.port}; HTTP → 127.0.0.1:${config.nextInternalPort}; ${MUX_PATH} → 127.0.0.1:${config.directTerminalPort}/mux`,
|
||||
);
|
||||
});
|
||||
|
||||
const onSignal = (): void => {
|
||||
void proxy.shutdown().then(() => process.exit(0));
|
||||
setTimeout(() => process.exit(1), SHUTDOWN_TIMEOUT_MS).unref();
|
||||
};
|
||||
process.on("SIGINT", onSignal);
|
||||
process.on("SIGTERM", onSignal);
|
||||
}
|
||||
|
||||
/** True when this file was run directly (`node single-port-server.js`). */
|
||||
function isMainModule(): boolean {
|
||||
if (!process.argv[1]) return false;
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
main();
|
||||
}
|
||||
|
|
@ -107,14 +107,32 @@ function resolveNextBin(): string {
|
|||
|
||||
// Start Next.js production server
|
||||
const port = process.env["PORT"] || "3000";
|
||||
const pathBasedMux = process.env["AO_PATH_BASED_MUX"] === "1";
|
||||
|
||||
// When AO_PATH_BASED_MUX=1, single-port-server.js owns PORT and Next.js is
|
||||
// shifted to PORT + 1000 (overridable via NEXT_INTERNAL_PORT). The proxy
|
||||
// forwards HTTP to Next.js and tunnels `/ao-terminal-mux` WS upgrades to
|
||||
// direct-terminal-ws. Default off — Next.js stays on PORT directly.
|
||||
const NEXT_INTERNAL_OFFSET = 1000;
|
||||
const nextPort = pathBasedMux
|
||||
? (process.env["NEXT_INTERNAL_PORT"] ?? String(parseInt(port, 10) + NEXT_INTERNAL_OFFSET))
|
||||
: port;
|
||||
|
||||
const nextBin = resolveNextBin();
|
||||
|
||||
if (isWindows() && nextBin !== "next") {
|
||||
// On Windows, run the JS entry point via the current node binary.
|
||||
// spawn() can't execute .js files directly on Windows.
|
||||
spawnProcess("next", process.execPath, [nextBin, "start", "-p", port]);
|
||||
spawnProcess("next", process.execPath, [nextBin, "start", "-p", nextPort]);
|
||||
} else {
|
||||
spawnProcess("next", nextBin, ["start", "-p", port]);
|
||||
spawnProcess("next", nextBin, ["start", "-p", nextPort]);
|
||||
}
|
||||
|
||||
if (pathBasedMux) {
|
||||
// Surface the internal port to the child so it doesn't have to re-derive
|
||||
// the offset; pin it explicitly.
|
||||
process.env["NEXT_INTERNAL_PORT"] = nextPort;
|
||||
spawnProcess("single-port", process.execPath, [resolve(__dirname, "single-port-server.js")]);
|
||||
}
|
||||
|
||||
// Start direct terminal WebSocket server (auto-restart on crash)
|
||||
|
|
|
|||
Loading…
Reference in New Issue