Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.hub.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/hub/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ export async function createHubRuntime(
const app = new Hono<AppEnv>();
const liveAppProxy = createLiveAppProxy({
hostname: env.hostname,
ports: env.liveAppPorts,
publicOrigin: new URL(env.uiOrigin),
hubCookiePrefix: sessionCookieName.split(".")[0] + ".",
authorize: async (request, scope) => {
Expand Down
20 changes: 15 additions & 5 deletions apps/hub/src/artifacts/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,11 +555,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.
Expand Down
17 changes: 17 additions & 0 deletions apps/hub/src/environment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import {
readDatabaseConfig,
readHubEnvironment,
readLiveAppPorts,
readMigrationDatabaseConfig,
} from "./environment.ts";

Expand Down Expand Up @@ -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();
});
});
18 changes: 18 additions & 0 deletions apps/hub/src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -211,5 +228,6 @@ export function readHubEnvironment(env: Environment) {
hubApiOrigin: hubApiOrigin.origin,
uiOrigin: uiOrigin.origin,
allowedEmails: readAllowedEmails(env),
liveAppPorts: readLiveAppPorts(env),
};
}
154 changes: 99 additions & 55 deletions apps/hub/src/live-app/proxy.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<T>(
serveAt: (port: number) => Bun.Server<T>,
ports: LiveAppPorts | undefined,
taken: ReadonlySet<number>,
): Bun.Server<T> {
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<boolean>;
Expand Down Expand Up @@ -101,64 +130,79 @@ export function createLiveAppProxy(options: {

function listen(scope: LiveAppScope, target: URL): Exposed {
const entry = { ...scope, target, lastUsed: Date.now() } as Exposed;
entry.server = Bun.serve<Upstream>({
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<Upstream>({
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 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 });
},
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;
Expand Down
4 changes: 4 additions & 0 deletions deploy/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand Down
5 changes: 4 additions & 1 deletion docs/SELF_HOST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down