From 31cd06eed9610be7be58ba056a0957e5edd1d990 Mon Sep 17 00:00:00 2001 From: "Brian J. Fox" Date: Mon, 2 Nov 2026 10:06:36 +0000 Subject: [PATCH] fix(hub): serve live previews from a port range Compose can publish --- .env.hub.example | 4 + apps/hub/src/app.ts | 1 + apps/hub/src/artifacts/service.ts | 20 +++- apps/hub/src/environment.test.ts | 17 ++++ apps/hub/src/environment.ts | 18 ++++ apps/hub/src/live-app/proxy.ts | 156 +++++++++++++++++++----------- deploy/compose.yml | 4 + docs/SELF_HOST.md | 5 +- 8 files changed, 164 insertions(+), 61 deletions(-) diff --git a/.env.hub.example b/.env.hub.example index ae24ad41..b840fac5 100644 --- a/.env.hub.example +++ b/.env.hub.example @@ -32,6 +32,10 @@ PRINCIPAL_KEY_ENCRYPTION_KEY= # --- Hub server -------------------------------------------------------------- # Builder's port: the hub with `bun run dev`, or what Docker Compose publishes. PORT=8000 +# The ports the live preview serves each open project's app on, one per project. +# Docker Compose publishes this range (default 4100-4119) beside PORT; with +# `bun run dev` leave it unset and any free port serves. +# LIVE_APP_PORTS=4100-4119 # The address people sign in at. Defaults to http://localhost:$PORT; set it when # people reach Builder at another address, such as a domain. # BETTER_AUTH_BASE_URL=https://builder.example.com diff --git a/apps/hub/src/app.ts b/apps/hub/src/app.ts index 689919ee..849700c9 100644 --- a/apps/hub/src/app.ts +++ b/apps/hub/src/app.ts @@ -386,6 +386,7 @@ export async function createHubRuntime( const app = new Hono(); const liveAppProxy = createLiveAppProxy({ hostname: env.hostname, + ports: env.liveAppPorts, publicOrigin: new URL(env.uiOrigin), hubCookiePrefix: sessionCookieName.split(".")[0] + ".", authorize: async (request, scope) => { diff --git a/apps/hub/src/artifacts/service.ts b/apps/hub/src/artifacts/service.ts index 11dfe165..4692cbbf 100644 --- a/apps/hub/src/artifacts/service.ts +++ b/apps/hub/src/artifacts/service.ts @@ -639,11 +639,21 @@ export function createArtifactService( if (run?.state !== "running") proxy.withdraw(projectId); if (!run) return { state: "stopped", ...shown }; if (run.state !== "running") return { ...run, ...shown }; - return { - state: "running", - ...shown, - url: proxy.expose({ tenantId: routeTenantId, projectId }, run.url), - }; + // With every port of a configured range in use, the app runs but cannot be shown. + try { + return { + state: "running", + ...shown, + url: proxy.expose({ tenantId: routeTenantId, projectId }, run.url), + }; + } catch (error) { + log.warn("Exposing the live app failed", requestErrorMetadata(error)); + return { + state: "failed", + error: error instanceof Error ? error.message : String(error), + ...shown, + }; + } } // The concept of the designer run writing as actor: a workflow principal's ref is its run. diff --git a/apps/hub/src/environment.test.ts b/apps/hub/src/environment.test.ts index 1705e8c1..6232a0fa 100644 --- a/apps/hub/src/environment.test.ts +++ b/apps/hub/src/environment.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { readDatabaseConfig, readHubEnvironment, + readLiveAppPorts, readMigrationDatabaseConfig, } from "./environment.ts"; @@ -137,3 +138,19 @@ describe("hub environment security boundary", () => { ).toThrow("set together"); }); }); + +describe("live-app port range", () => { + test("reads a range, takes any port without one, and refuses a malformed one", () => { + expect(readLiveAppPorts({ LIVE_APP_PORTS: "4100-4119" })).toEqual({ + from: 4100, + to: 4119, + }); + expect(readLiveAppPorts({})).toBeUndefined(); + expect(readLiveAppPorts({ LIVE_APP_PORTS: "" })).toBeUndefined(); + for (const raw of ["4100", "4119-4100", "0-10", "4100-70000", "a-b"]) + expect(() => readLiveAppPorts({ LIVE_APP_PORTS: raw })).toThrow( + "LIVE_APP_PORTS", + ); + expect(readHubEnvironment(environment).liveAppPorts).toBeUndefined(); + }); +}); diff --git a/apps/hub/src/environment.ts b/apps/hub/src/environment.ts index ae751868..082fc321 100644 --- a/apps/hub/src/environment.ts +++ b/apps/hub/src/environment.ts @@ -154,6 +154,23 @@ export function positiveInteger( return value; } +/** + * The ports the live-app proxy serves projects' apps on, as "4100-4119", or undefined to take any + * free port. A deployment that publishes ports, such as Compose, needs the range known in advance. + */ +export function readLiveAppPorts( + env: Environment, +): { from: number; to: number } | undefined { + const raw = env["LIVE_APP_PORTS"]?.trim(); + if (raw === undefined || raw === "") return undefined; + const match = /^(\d{1,5})-(\d{1,5})$/.exec(raw); + const from = Number(match?.[1]); + const to = Number(match?.[2]); + if (!match || from < 1 || to > 65535 || from > to) + throw new Error("LIVE_APP_PORTS must be a port range such as 4100-4119"); + return { from, to }; +} + export function readHubEnvironment(env: Environment) { const credentialKey = required(env, "CREDENTIAL_ENCRYPTION_KEY"); const principalKey = required(env, "PRINCIPAL_KEY_ENCRYPTION_KEY"); @@ -211,5 +228,6 @@ export function readHubEnvironment(env: Environment) { hubApiOrigin: hubApiOrigin.origin, uiOrigin: uiOrigin.origin, allowedEmails: readAllowedEmails(env), + liveAppPorts: readLiveAppPorts(env), }; } diff --git a/apps/hub/src/live-app/proxy.ts b/apps/hub/src/live-app/proxy.ts index 9ca27c79..c22f77d3 100644 --- a/apps/hub/src/live-app/proxy.ts +++ b/apps/hub/src/live-app/proxy.ts @@ -1,4 +1,5 @@ import { getLogger } from "@intx/log"; +import { hasCode } from "@intx/types/has-code"; import type { MiddlewareHandler } from "hono"; import { liveAppWording } from "../config/live-app.ts"; import { errorCode, requestErrorMetadata } from "../request-error.ts"; @@ -36,8 +37,36 @@ const HOP_BY_HOP = [ // pages. Cookies ignore ports, so the browser sends the hub session along; each request is let // through only when the hub would show that person the project, and the hub's cookies never // reach the app. +export type LiveAppPorts = { from: number; to: number }; + +/** + * A server on the first free port of the range, skipping the ports the other apps hold. A + * deployment that publishes ports, such as Compose, can publish a range but never a port chosen at + * random; without a range any free port serves, as on a developer's machine. + */ +function serveWithin( + serveAt: (port: number) => Bun.Server, + ports: LiveAppPorts | undefined, + taken: ReadonlySet, +): Bun.Server { + if (ports === undefined) return serveAt(0); + for (let port = ports.from; port <= ports.to; port += 1) { + if (taken.has(port)) continue; + try { + return serveAt(port); + } catch (error) { + if (!(hasCode(error) && error.code === "EADDRINUSE")) throw error; + } + } + throw new Error( + `live_app_ports_exhausted: every port from ${String(ports.from)} to ${String(ports.to)} is in use; widen LIVE_APP_PORTS`, + ); +} + export function createLiveAppProxy(options: { hostname: string; + /** The range to serve apps from, or undefined to take any free port. */ + ports?: LiveAppPorts | undefined; publicOrigin: URL; hubCookiePrefix: string; authorize: (request: Request, scope: LiveAppScope) => Promise; @@ -101,64 +130,81 @@ export function createLiveAppProxy(options: { function listen(scope: LiveAppScope, target: URL): Exposed { const entry = { ...scope, target, lastUsed: Date.now() } as Exposed; - entry.server = Bun.serve({ - hostname: options.hostname, - port: 0, - error(error) { - log.warn("Live app proxy request failed", { - ...errorCode(error), - ...requestErrorMetadata(error), - }); - return new Response(liveAppWording.unreachable, { status: 502 }); - }, - fetch: async (request, server) => { - if (!(await allows(request, entry))) - return new Response(liveAppWording.signIn, { status: 401 }); - entry.lastUsed = Date.now(); - if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") - return forward(request, entry); - const url = new URL(request.url); - const socketUrl = new URL(url.pathname + url.search, entry.target); - socketUrl.protocol = "ws:"; - const protocols = (request.headers.get("sec-websocket-protocol") ?? "") - .split(",") - .map((protocol) => protocol.trim()) - .filter(Boolean); - const upgraded = server.upgrade(request, { - data: { url: socketUrl.href, protocols, pending: [] }, - ...(protocols[0] - ? { headers: { "sec-websocket-protocol": protocols[0] } } - : {}), - }); - return upgraded ? undefined : new Response(null, { status: 400 }); - }, - websocket: { - open(client) { - const socket = new WebSocket(client.data.url, client.data.protocols); - socket.binaryType = "arraybuffer"; - socket.onopen = () => { - for (const message of client.data.pending.splice(0)) - socket.send(message); - }; - socket.onmessage = (event) => { - entry.lastUsed = Date.now(); - client.send(event.data as string | ArrayBuffer); - }; - socket.onclose = (event) => - client.close(event.code === 1005 ? 1000 : event.code); - socket.onerror = () => client.close(1011); - client.data.socket = socket; + const serveAt = (port: number) => + Bun.serve({ + hostname: options.hostname, + port, + error(error) { + log.warn("Live app proxy request failed", { + ...errorCode(error), + ...requestErrorMetadata(error), + }); + return new Response(liveAppWording.unreachable, { status: 502 }); }, - message(client, message) { - const socket = client.data.socket; - if (socket?.readyState === WebSocket.OPEN) socket.send(message); - else client.data.pending.push(message); + fetch: async (request, server) => { + if (!(await allows(request, entry))) + return new Response(liveAppWording.signIn, { status: 401 }); + entry.lastUsed = Date.now(); + if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") + return forward(request, entry); + const url = new URL(request.url); + const socketUrl = new URL(url.pathname + url.search, entry.target); + socketUrl.protocol = "ws:"; + const offered = request.headers.get("sec-websocket-protocol"); + const protocols = + offered === null + ? [] + : offered + .split(",") + .map((protocol) => protocol.trim()) + .filter(Boolean); + const upgraded = server.upgrade(request, { + data: { url: socketUrl.href, protocols, pending: [] }, + ...(protocols[0] + ? { headers: { "sec-websocket-protocol": protocols[0] } } + : {}), + }); + return upgraded ? undefined : new Response(null, { status: 400 }); }, - close(client) { - client.data.socket?.close(); + websocket: { + open(client) { + const socket = new WebSocket( + client.data.url, + client.data.protocols, + ); + socket.binaryType = "arraybuffer"; + socket.onopen = () => { + for (const message of client.data.pending.splice(0)) + socket.send(message); + }; + socket.onmessage = (event) => { + entry.lastUsed = Date.now(); + client.send(event.data as string | ArrayBuffer); + }; + socket.onclose = (event) => + client.close(event.code === 1005 ? 1000 : event.code); + socket.onerror = () => client.close(1011); + client.data.socket = socket; + }, + message(client, message) { + const socket = client.data.socket; + if (socket?.readyState === WebSocket.OPEN) socket.send(message); + else client.data.pending.push(message); + }, + close(client) { + client.data.socket?.close(); + }, }, - }, - }); + }); + entry.server = serveWithin( + serveAt, + options.ports, + new Set( + [...exposed.values()].flatMap((item) => + item.server.port === undefined ? [] : [item.server.port], + ), + ), + ); const origin = new URL(options.publicOrigin); origin.port = String(entry.server.port); entry.origin = origin.origin; diff --git a/deploy/compose.yml b/deploy/compose.yml index 183e79fb..a55745ae 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -18,6 +18,8 @@ x-hub: &hub MIGRATION_DATABASE_URL: "" HUB_WEB_DIR: /app/apps/web/dist BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-http://localhost:${PORT:-8000}} + # Published below on the hub service, so the browser outside the container can reach them. + LIVE_APP_PORTS: ${LIVE_APP_PORTS:-4100-4119} depends_on: db: condition: service_healthy @@ -57,6 +59,8 @@ services: - "127.0.0.1:${PORT:-8000}:${PORT:-8000}" - "127.0.0.1:1455:1455" - "127.0.0.1:1456:1456" + # The live preview serves each open project's app on a port of its own from this range. + - "127.0.0.1:${LIVE_APP_PORTS:-4100-4119}:${LIVE_APP_PORTS:-4100-4119}" setup: <<: *hub profiles: ["setup"] diff --git a/docs/SELF_HOST.md b/docs/SELF_HOST.md index 0a55f7c7..24efe3c6 100644 --- a/docs/SELF_HOST.md +++ b/docs/SELF_HOST.md @@ -31,7 +31,10 @@ setting is optional and described in Behind a proxy or on a public domain, set `BETTER_AUTH_BASE_URL` to the URL people open, and `HUB_PUBLIC_URL`, `HUB_API_ORIGIN` and `UI_PUBLIC_URL` when they differ from it. If a port is taken, `PORT` moves Builder (default `8000`) -and `DB_PORT` the database (default `5433`). +and `DB_PORT` the database (default `5433`). The live preview serves each open +project's app on a port of its own from `LIVE_APP_PORTS` (default `4100-4119`), +which Compose publishes on loopback beside `PORT`; widen the range when more +projects than that have their app open at once. `BUILDER_VERSION` picks the published images (`ghcr.io/corbitsdev/corbits-builder-hub` and `-sidecar`): `rc` (default, the