Add configurable service proxy (#1280)

* Add configurable service proxy

* Fix service proxy URL used when opening scripts with a public base URL

When a public proxy URL was configured, the View link was falling through
to direct connection logic and opening the wrong URL. Move the public URL
early-return before connection-type checks, and remove the now-dead
duplicate check further down.

* Add service proxy documentation

Covers config options, URL generation format, wildcard DNS setup,
and reverse proxy Host header requirements.

* Cap combined public service label and document hostname format

* Address service proxy review feedback
This commit is contained in:
Matt Cowger
2026-06-02 14:08:41 +08:00
committed by GitHub
parent eac5d93f78
commit f1a20a9fe9
19 changed files with 614 additions and 36 deletions
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { buildScriptHostname } from "./script-hostname.js";
import {
buildPublicScriptHostname,
buildPublicScriptProxyUrl,
buildScriptHostname,
} from "./script-hostname.js";
describe("buildScriptHostname", () => {
it("builds default branch hostnames with script and project labels", () => {
@@ -69,3 +73,54 @@ describe("buildScriptHostname", () => {
).toBe("untitled.untitled.untitled.localhost");
});
});
describe("buildPublicScriptHostname", () => {
it("uses one combined service label under the configured public base host", () => {
expect(
buildPublicScriptHostname({
publicBaseUrl: "https://services.example.com",
projectSlug: "paseo",
branchName: "feature-auth",
scriptName: "web",
}),
).toBe("web--feature-auth--paseo.services.example.com");
});
it("omits default branch names from the public service label", () => {
expect(
buildPublicScriptHostname({
publicBaseUrl: "https://services.example.com",
projectSlug: "paseo",
branchName: "main",
scriptName: "web",
}),
).toBe("web--paseo.services.example.com");
});
it("caps the public service label to the DNS label length limit", () => {
const hostname = buildPublicScriptHostname({
publicBaseUrl: "https://services.example.com",
projectSlug: "project-".repeat(10),
branchName: "branch-".repeat(10),
scriptName: "script-".repeat(10),
});
const [serviceLabel] = hostname.split(".");
expect(serviceLabel.length).toBeLessThanOrEqual(63);
expect(serviceLabel).toMatch(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/);
expect(hostname).toBe(`${serviceLabel}.services.example.com`);
});
});
describe("buildPublicScriptProxyUrl", () => {
it("preserves the configured public base protocol and port", () => {
expect(
buildPublicScriptProxyUrl({
publicBaseUrl: "https://services.example.com:8443/base-is-ignored",
projectSlug: "paseo",
branchName: "feature-auth",
scriptName: "web",
}),
).toBe("https://web--feature-auth--paseo.services.example.com:8443");
});
});
@@ -1,15 +1,48 @@
import { slugify } from "./worktree.js";
const MAX_DNS_LABEL_LENGTH = 63;
interface BuildScriptHostnameOptions {
projectSlug: string;
branchName: string | null;
scriptName: string;
}
interface BuildPublicScriptHostnameOptions extends BuildScriptHostnameOptions {
publicBaseUrl: string;
}
function toHostnameLabel(value: string): string {
return slugify(value) || "untitled";
}
function capDnsLabel(label: string): string {
if (label.length <= MAX_DNS_LABEL_LENGTH) {
return label;
}
return label.slice(0, MAX_DNS_LABEL_LENGTH).replace(/-+$/g, "") || "untitled";
}
function toPublicServiceLabel({
projectSlug,
branchName,
scriptName,
}: BuildScriptHostnameOptions): string {
const labels = [toHostnameLabel(scriptName)];
const isDefaultBranch = branchName === null || branchName === "main" || branchName === "master";
if (!isDefaultBranch) {
labels.push(toHostnameLabel(branchName));
}
labels.push(toHostnameLabel(projectSlug));
// Public URLs must keep script/branch/project in one label. The local
// script.branch.project.localhost shape would require multi-level wildcard
// DNS/certificates for arbitrary branch names, while a single label works with
// normal `*.base-domain` DNS and wildcard TLS. Cap it to the DNS 63-octet
// label limit so long branch/project/script names still produce resolvable URLs.
return capDnsLabel(labels.join("--"));
}
export function buildScriptHostname({
projectSlug,
branchName,
@@ -25,3 +58,18 @@ export function buildScriptHostname({
return `${serviceHostnameLabel}.${toHostnameLabel(branchName)}.${projectHostnameLabel}.localhost`;
}
export function buildPublicScriptHostname({
publicBaseUrl,
...script
}: BuildPublicScriptHostnameOptions): string {
const base = new URL(publicBaseUrl);
return `${toPublicServiceLabel(script)}.${base.hostname}`;
}
export function buildPublicScriptProxyUrl(options: BuildPublicScriptHostnameOptions): string {
const base = new URL(options.publicBaseUrl);
const hostname = buildPublicScriptHostname(options);
const port = base.port ? `:${base.port}` : "";
return `${base.protocol}//${hostname}${port}`;
}