mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -36,6 +36,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
|
||||
90
docs/service-proxy.md
Normal file
90
docs/service-proxy.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Service Proxy
|
||||
|
||||
Paseo can proxy HTTP traffic to services running inside your workspaces and expose them at stable, publicly-accessible URLs. This lets you open a running dev server from your phone or share it with a teammate without any tunneling tools.
|
||||
|
||||
## How it works
|
||||
|
||||
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port.
|
||||
|
||||
The generated hostname is built from the script name, branch, and project:
|
||||
|
||||
```
|
||||
<script>--<branch>--<project>.<base-domain>
|
||||
```
|
||||
|
||||
If the branch is `main` or `master`, the branch segment is omitted:
|
||||
|
||||
```
|
||||
<script>--<project>.<base-domain>
|
||||
```
|
||||
|
||||
**Example:** a script named `dev` in the `miniweb` project on branch `feature/auth` would be reachable at:
|
||||
|
||||
```
|
||||
dev--feature-auth--miniweb.paseoapps.my.domain.com
|
||||
```
|
||||
|
||||
The public route uses one combined leftmost label (`script--branch--project`) rather than the local development shape (`script.branch.project.localhost`). This is intentional: dotted script/branch/project hostnames would require multi-level wildcard DNS and certificates for arbitrary branch names, while a single label works with normal `*.base-domain` DNS and wildcard TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"daemon": {
|
||||
"serviceProxy": {
|
||||
"enabled": true,
|
||||
"listen": "0.0.0.0:8080",
|
||||
"publicBaseUrl": "https://paseoapps.my.domain.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `enabled` | No | Enables the proxy. Defaults to `true` if `publicBaseUrl` is set, `false` otherwise. |
|
||||
| `listen` | No | Address and port the proxy listens on. Defaults to `127.0.0.1:6868`. |
|
||||
| `publicBaseUrl` | No | Base URL used to generate public service links. If omitted, links use localhost addresses only. |
|
||||
|
||||
## DNS and reverse proxy setup
|
||||
|
||||
For generated URLs to be reachable, you need wildcard DNS pointing to the machine running the Paseo daemon.
|
||||
|
||||
**Example:** to expose services at `https://dev--miniweb.paseoapps.my.domain.com` where the daemon host is `10.1.1.1`:
|
||||
|
||||
1. Configure a wildcard DNS record:
|
||||
|
||||
```
|
||||
*.paseoapps.my.domain.com → 10.1.1.1
|
||||
```
|
||||
|
||||
2. Set `publicBaseUrl` to `https://paseoapps.my.domain.com` in your config.
|
||||
|
||||
3. If you put a reverse proxy (nginx, Caddy, Traefik, etc.) in front of the daemon, ensure it forwards the `Host` header unchanged. The proxy uses the `Host` header to route requests to the correct service — rewriting it will break routing.
|
||||
|
||||
Nginx example:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name *.paseoapps.my.domain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://10.1.1.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`:
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------------------- | ----------------------------------- |
|
||||
| `PASEO_SERVICE_PROXY_ENABLED` | `true` or `false` |
|
||||
| `PASEO_SERVICE_PROXY_LISTEN` | Listen address, e.g. `0.0.0.0:8080` |
|
||||
| `PASEO_SERVICE_PROXY_PUBLIC_BASE_URL` | Public base URL |
|
||||
@@ -14,6 +14,19 @@ function isLoopbackHost(host: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isLocalOnlyUrl(url: string | null | undefined): boolean {
|
||||
if (!url) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
return isLoopbackHost(hostname) || hostname.endsWith(".localhost");
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDirectServiceUrl(endpoint: string, port: number): string | null {
|
||||
try {
|
||||
const { host, isIpv6 } = parseHostPort(endpoint);
|
||||
@@ -37,6 +50,12 @@ export function resolveWorkspaceScriptLink(input: {
|
||||
return { openUrl: null, labelUrl: script.proxyUrl };
|
||||
}
|
||||
|
||||
const proxyUrlIsLocalOnly = isLocalOnlyUrl(script.proxyUrl);
|
||||
|
||||
if (!proxyUrlIsLocalOnly) {
|
||||
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl };
|
||||
}
|
||||
|
||||
if (activeConnection.type === "relay") {
|
||||
return { openUrl: null, labelUrl: script.proxyUrl };
|
||||
}
|
||||
|
||||
@@ -249,6 +249,11 @@ export interface PaseoDaemonConfig {
|
||||
relayPublicEndpoint?: string;
|
||||
relayUseTls?: boolean;
|
||||
relayPublicUseTls?: boolean;
|
||||
serviceProxy?: {
|
||||
enabled: boolean;
|
||||
listen: string;
|
||||
publicBaseUrl: string | null;
|
||||
};
|
||||
appBaseUrl?: string;
|
||||
auth?: DaemonAuthConfig;
|
||||
openai?: PaseoOpenAIConfig;
|
||||
@@ -334,7 +339,13 @@ export async function createPaseoDaemon(
|
||||
const scriptRouteStore = new ScriptRouteStore();
|
||||
const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
|
||||
const configuredHostnames = config.hostnames ?? config.allowedHosts;
|
||||
const serviceProxyPublicBaseUrl =
|
||||
config.serviceProxy?.enabled && config.serviceProxy.publicBaseUrl
|
||||
? config.serviceProxy.publicBaseUrl
|
||||
: null;
|
||||
let wsServer: VoiceAssistantWebSocketServer | null = null;
|
||||
let serviceProxyHttpServer: ReturnType<typeof createHTTPServer> | null = null;
|
||||
let serviceProxyListenTarget: ListenTarget | null = null;
|
||||
const scriptHealthMonitor = new ScriptHealthMonitor({
|
||||
routeStore: scriptRouteStore,
|
||||
onChange: createScriptStatusEmitter({
|
||||
@@ -348,6 +359,7 @@ export async function createPaseoDaemon(
|
||||
resolveWorkspaceDirectory: async (workspaceId) =>
|
||||
(await workspaceRegistry?.get(workspaceId))?.cwd ?? null,
|
||||
logger,
|
||||
serviceProxyPublicBaseUrl,
|
||||
}),
|
||||
});
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
@@ -501,6 +513,30 @@ export async function createPaseoDaemon(
|
||||
});
|
||||
httpServer.on("upgrade", scriptProxyUpgradeHandler);
|
||||
|
||||
if (config.serviceProxy?.enabled) {
|
||||
const serviceProxyTarget = parseListenString(config.serviceProxy.listen);
|
||||
const serviceProxyApp = express();
|
||||
serviceProxyApp.set("trust proxy", true);
|
||||
serviceProxyApp.use(createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger }));
|
||||
serviceProxyApp.use((_req, res) => {
|
||||
res.status(404).send("404 Not Found");
|
||||
});
|
||||
serviceProxyHttpServer = createHTTPServer(serviceProxyApp);
|
||||
const serviceProxyUpgradeHandler = createScriptProxyUpgradeHandler({
|
||||
routeStore: scriptRouteStore,
|
||||
logger,
|
||||
});
|
||||
serviceProxyHttpServer.on("upgrade", (req, socket, head) => {
|
||||
const hostHeader = req.headers.host;
|
||||
if (!hostHeader || !scriptRouteStore.findRoute(hostHeader)) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
serviceProxyUpgradeHandler(req, socket as import("node:net").Socket, head);
|
||||
});
|
||||
serviceProxyListenTarget = serviceProxyTarget;
|
||||
}
|
||||
|
||||
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
|
||||
const projectRegistry = new FileBackedProjectRegistry(
|
||||
path.join(config.paseoHome, "projects", "projects.json"),
|
||||
@@ -737,6 +773,7 @@ export async function createPaseoDaemon(
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
|
||||
getDaemonTcpHost: () =>
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.host : null,
|
||||
serviceProxyPublicBaseUrl,
|
||||
onScriptsChanged: null,
|
||||
},
|
||||
input,
|
||||
@@ -979,6 +1016,7 @@ export async function createPaseoDaemon(
|
||||
publicUseTls: relayPublicUseTls,
|
||||
},
|
||||
},
|
||||
serviceProxyPublicBaseUrl,
|
||||
);
|
||||
|
||||
if (relayEnabled) {
|
||||
@@ -1025,6 +1063,41 @@ export async function createPaseoDaemon(
|
||||
}
|
||||
});
|
||||
|
||||
if (serviceProxyHttpServer && serviceProxyListenTarget) {
|
||||
const proxyServer = serviceProxyHttpServer;
|
||||
const proxyTarget = serviceProxyListenTarget;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => {
|
||||
proxyServer.off("listening", onListening);
|
||||
reject(err);
|
||||
};
|
||||
const onListening = () => {
|
||||
proxyServer.off("error", onError);
|
||||
serviceProxyListenTarget = resolveBoundListenTarget(proxyTarget, proxyServer);
|
||||
logger.info(
|
||||
{
|
||||
listen: formatListenTarget(serviceProxyListenTarget),
|
||||
publicBaseUrl: serviceProxyPublicBaseUrl,
|
||||
elapsed: elapsed(),
|
||||
},
|
||||
"Service proxy listening",
|
||||
);
|
||||
resolve();
|
||||
};
|
||||
proxyServer.once("error", onError);
|
||||
proxyServer.once("listening", onListening);
|
||||
|
||||
if (proxyTarget.type === "tcp") {
|
||||
proxyServer.listen(proxyTarget.port, proxyTarget.host);
|
||||
} else {
|
||||
if (proxyTarget.type === "socket" && existsSync(proxyTarget.path)) {
|
||||
unlinkSync(proxyTarget.path);
|
||||
}
|
||||
proxyServer.listen(proxyTarget.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start speech service after listening so synchronous Sherpa native
|
||||
// model loading doesn't block the server from accepting connections.
|
||||
speechService.start();
|
||||
@@ -1045,6 +1118,18 @@ export async function createPaseoDaemon(
|
||||
if (wsServer) {
|
||||
await wsServer.close();
|
||||
}
|
||||
if (serviceProxyHttpServer) {
|
||||
serviceProxyHttpServer.closeAllConnections();
|
||||
await new Promise<void>((resolve) => {
|
||||
serviceProxyHttpServer?.close(() => resolve());
|
||||
});
|
||||
if (
|
||||
serviceProxyListenTarget?.type === "socket" &&
|
||||
existsSync(serviceProxyListenTarget.path)
|
||||
) {
|
||||
unlinkSync(serviceProxyListenTarget.path);
|
||||
}
|
||||
}
|
||||
// Force-drop remaining sockets so httpServer.close() resolves promptly.
|
||||
// We've already closed wsServer (which sent ws-layer close frames) and
|
||||
// stopped every other service, so anything still attached is a TCP
|
||||
|
||||
@@ -84,6 +84,43 @@ describe("daemon relay config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("daemon service proxy config", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
test("loads public base URL from env before persisted config", async () => {
|
||||
const home = await createPaseoHome({
|
||||
version: 1,
|
||||
daemon: {
|
||||
serviceProxy: {
|
||||
publicBaseUrl: "https://persisted.example.com",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const config = loadConfig(home, {
|
||||
env: { PASEO_SERVICE_PROXY_PUBLIC_BASE_URL: "https://env.example.com/" },
|
||||
});
|
||||
|
||||
expect(config.serviceProxy).toEqual({
|
||||
enabled: true,
|
||||
listen: "127.0.0.1:6868",
|
||||
publicBaseUrl: "https://env.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects invalid PASEO_SERVICE_PROXY_PUBLIC_BASE_URL values", async () => {
|
||||
const home = await createPaseoHome({ version: 1 });
|
||||
|
||||
expect(() =>
|
||||
loadConfig(home, {
|
||||
env: { PASEO_SERVICE_PROXY_PUBLIC_BASE_URL: "not-a-url" },
|
||||
}),
|
||||
).toThrow("Invalid PASEO_SERVICE_PROXY_PUBLIC_BASE_URL: not-a-url");
|
||||
});
|
||||
});
|
||||
|
||||
describe("daemon worktree root config", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
|
||||
@@ -24,6 +24,7 @@ import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostn
|
||||
const DEFAULT_PORT = 6767;
|
||||
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
||||
const DEFAULT_APP_BASE_URL = "https://app.paseo.sh";
|
||||
const DEFAULT_SERVICE_PROXY_LISTEN = "127.0.0.1:6868";
|
||||
|
||||
function parseBooleanEnv(value: string | undefined): boolean | undefined {
|
||||
if (value === undefined) {
|
||||
@@ -152,6 +153,12 @@ interface ResolvedRelay {
|
||||
publicUseTls: boolean;
|
||||
}
|
||||
|
||||
interface ResolvedServiceProxy {
|
||||
enabled: boolean;
|
||||
listen: string;
|
||||
publicBaseUrl: string | null;
|
||||
}
|
||||
|
||||
function resolveTlsFromEnv(
|
||||
envValue: string | undefined,
|
||||
persistedValue: boolean | undefined,
|
||||
@@ -198,6 +205,38 @@ interface ResolvedVoiceLlm {
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
function resolveServiceProxyPublicBaseUrl(value: string | null): string | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(value).toString().replace(/\/$/, "");
|
||||
} catch {
|
||||
throw new Error(`Invalid PASEO_SERVICE_PROXY_PUBLIC_BASE_URL: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveServiceProxyConfig(
|
||||
env: NodeJS.ProcessEnv,
|
||||
persisted: ReturnType<typeof loadPersistedConfig>,
|
||||
): ResolvedServiceProxy {
|
||||
const publicBaseUrl = resolveServiceProxyPublicBaseUrl(
|
||||
env.PASEO_SERVICE_PROXY_PUBLIC_BASE_URL ??
|
||||
persisted.daemon?.serviceProxy?.publicBaseUrl ??
|
||||
null,
|
||||
);
|
||||
const enabled =
|
||||
parseBooleanEnv(env.PASEO_SERVICE_PROXY_ENABLED) ??
|
||||
persisted.daemon?.serviceProxy?.enabled ??
|
||||
publicBaseUrl !== null;
|
||||
const listen =
|
||||
env.PASEO_SERVICE_PROXY_LISTEN ??
|
||||
persisted.daemon?.serviceProxy?.listen ??
|
||||
DEFAULT_SERVICE_PROXY_LISTEN;
|
||||
|
||||
return { enabled, listen, publicBaseUrl };
|
||||
}
|
||||
|
||||
function resolveVoiceLlmConfig(
|
||||
env: NodeJS.ProcessEnv,
|
||||
persisted: ReturnType<typeof loadPersistedConfig>,
|
||||
@@ -322,6 +361,7 @@ export function loadConfig(
|
||||
cliRelayEnabled: options?.cli?.relayEnabled,
|
||||
cliRelayUseTls: options?.cli?.relayUseTls,
|
||||
});
|
||||
const serviceProxy = resolveServiceProxyConfig(env, persisted);
|
||||
|
||||
const { openai, speech } = resolveSpeechConfig({
|
||||
paseoHome,
|
||||
@@ -354,6 +394,7 @@ export function loadConfig(
|
||||
relayPublicEndpoint: relay.publicEndpoint,
|
||||
relayUseTls: relay.useTls,
|
||||
relayPublicUseTls: relay.publicUseTls,
|
||||
serviceProxy,
|
||||
appBaseUrl,
|
||||
auth: resolveAuthConfig(env, persisted),
|
||||
openai,
|
||||
|
||||
@@ -237,6 +237,14 @@ export const PersistedConfigSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
serviceProxy: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
listen: z.string().optional(),
|
||||
publicBaseUrl: z.string().url().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
auth: DaemonAuthSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -33,30 +33,49 @@ export interface ScriptRouteEntry extends ScriptRoute {
|
||||
workspaceId: string;
|
||||
projectSlug: string;
|
||||
scriptName: string;
|
||||
publicHostname?: string | null;
|
||||
publicBaseUrl?: string | null;
|
||||
}
|
||||
|
||||
export class ScriptRouteStore {
|
||||
private routes = new Map<string, ScriptRouteEntry>();
|
||||
private hostnameAliases = new Map<string, string>();
|
||||
private workspaceHostnames = new Map<string, Set<string>>();
|
||||
|
||||
registerRoute(entry: ScriptRouteEntry): void {
|
||||
const previous = this.routes.get(entry.hostname);
|
||||
if (previous) {
|
||||
this.removeHostnameFromWorkspaceIndex(previous.workspaceId, previous.hostname);
|
||||
this.removeRoute(previous.hostname);
|
||||
}
|
||||
|
||||
const storedEntry = { ...entry };
|
||||
const { publicHostname, publicBaseUrl, ...requiredEntry } = entry;
|
||||
const storedEntry: ScriptRouteEntry = {
|
||||
...requiredEntry,
|
||||
...(publicHostname ? { publicHostname } : {}),
|
||||
...(publicBaseUrl ? { publicBaseUrl } : {}),
|
||||
};
|
||||
this.routes.set(storedEntry.hostname, storedEntry);
|
||||
for (const alias of this.getRouteHostnames(storedEntry)) {
|
||||
const aliasedCanonicalHostname = this.hostnameAliases.get(alias);
|
||||
if (aliasedCanonicalHostname && aliasedCanonicalHostname !== storedEntry.hostname) {
|
||||
this.removeRoute(aliasedCanonicalHostname);
|
||||
}
|
||||
this.hostnameAliases.set(alias, storedEntry.hostname);
|
||||
}
|
||||
this.addHostnameToWorkspaceIndex(storedEntry.workspaceId, storedEntry.hostname);
|
||||
}
|
||||
|
||||
removeRoute(hostname: string): void {
|
||||
const entry = this.routes.get(hostname);
|
||||
const canonicalHostname = this.hostnameAliases.get(hostname) ?? hostname;
|
||||
const entry = this.routes.get(canonicalHostname);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
this.routes.delete(hostname);
|
||||
this.removeHostnameFromWorkspaceIndex(entry.workspaceId, hostname);
|
||||
this.routes.delete(canonicalHostname);
|
||||
for (const alias of this.getRouteHostnames(entry)) {
|
||||
this.hostnameAliases.delete(alias);
|
||||
}
|
||||
this.removeHostnameFromWorkspaceIndex(entry.workspaceId, canonicalHostname);
|
||||
}
|
||||
|
||||
removeRouteForWorkspaceScript(params: { workspaceId: string; scriptName: string }): void {
|
||||
@@ -72,6 +91,9 @@ export class ScriptRouteStore {
|
||||
for (const [hostname, entry] of this.routes) {
|
||||
if (entry.port === port) {
|
||||
this.routes.delete(hostname);
|
||||
for (const alias of this.getRouteHostnames(entry)) {
|
||||
this.hostnameAliases.delete(alias);
|
||||
}
|
||||
this.removeHostnameFromWorkspaceIndex(entry.workspaceId, hostname);
|
||||
}
|
||||
}
|
||||
@@ -82,7 +104,7 @@ export class ScriptRouteStore {
|
||||
const hostname = host.replace(/:\d+$/, "");
|
||||
|
||||
// 1. Exact match
|
||||
const exactRoute = this.routes.get(hostname);
|
||||
const exactRoute = this.getRouteByHostname(hostname);
|
||||
if (exactRoute !== undefined) {
|
||||
return { hostname: exactRoute.hostname, port: exactRoute.port };
|
||||
}
|
||||
@@ -91,7 +113,7 @@ export class ScriptRouteStore {
|
||||
const parts = hostname.split(".");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const candidate = parts.slice(i).join(".");
|
||||
const candidateRoute = this.routes.get(candidate);
|
||||
const candidateRoute = this.getRouteByHostname(candidate);
|
||||
if (candidateRoute !== undefined) {
|
||||
return { hostname: candidateRoute.hostname, port: candidateRoute.port };
|
||||
}
|
||||
@@ -101,7 +123,7 @@ export class ScriptRouteStore {
|
||||
}
|
||||
|
||||
getRouteEntry(hostname: string): ScriptRouteEntry | null {
|
||||
const entry = this.routes.get(hostname);
|
||||
const entry = this.getRouteByHostname(hostname);
|
||||
return entry ? { ...entry } : null;
|
||||
}
|
||||
|
||||
@@ -125,6 +147,17 @@ export class ScriptRouteStore {
|
||||
return routes;
|
||||
}
|
||||
|
||||
private getRouteByHostname(hostname: string): ScriptRouteEntry | undefined {
|
||||
const canonicalHostname = this.hostnameAliases.get(hostname) ?? hostname;
|
||||
return this.routes.get(canonicalHostname);
|
||||
}
|
||||
|
||||
private getRouteHostnames(
|
||||
entry: Pick<ScriptRouteEntry, "hostname" | "publicHostname">,
|
||||
): string[] {
|
||||
return [entry.hostname, ...(entry.publicHostname ? [entry.publicHostname] : [])];
|
||||
}
|
||||
|
||||
private addHostnameToWorkspaceIndex(workspaceId: string, hostname: string): void {
|
||||
const hostnames = this.workspaceHostnames.get(workspaceId) ?? new Set<string>();
|
||||
hostnames.add(hostname);
|
||||
|
||||
@@ -49,12 +49,16 @@ function registerRoute(
|
||||
workspaceId = "workspace-a",
|
||||
projectSlug = "paseo",
|
||||
scriptName,
|
||||
publicHostname,
|
||||
publicBaseUrl,
|
||||
}: {
|
||||
hostname: string;
|
||||
port: number;
|
||||
workspaceId?: string;
|
||||
projectSlug?: string;
|
||||
scriptName: string;
|
||||
publicHostname?: string | null;
|
||||
publicBaseUrl?: string | null;
|
||||
},
|
||||
): void {
|
||||
routeStore.registerRoute({
|
||||
@@ -63,6 +67,8 @@ function registerRoute(
|
||||
workspaceId,
|
||||
projectSlug,
|
||||
scriptName,
|
||||
...(publicHostname ? { publicHostname } : {}),
|
||||
...(publicBaseUrl ? { publicBaseUrl } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,6 +157,42 @@ describe("script-route-branch-handler", () => {
|
||||
expect(onRoutesChanged).toHaveBeenCalledWith("workspace-a");
|
||||
});
|
||||
|
||||
it("updates public route aliases from the stored public base URL", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
registerRoute(routeStore, {
|
||||
hostname: "api.feature-auth.paseo.localhost",
|
||||
publicHostname: "api--feature-auth--paseo.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com:8443",
|
||||
port: 3001,
|
||||
scriptName: "api",
|
||||
});
|
||||
|
||||
const onRoutesChanged = vi.fn();
|
||||
const handleBranchChange = createBranchChangeRouteHandler({
|
||||
routeStore,
|
||||
onRoutesChanged,
|
||||
});
|
||||
|
||||
handleBranchChange("workspace-a", "feature/auth", "feature/billing");
|
||||
|
||||
expect(routeStore.findRoute("api--feature-auth--paseo.services.example.com")).toBeNull();
|
||||
expect(routeStore.findRoute("api--feature-billing--paseo.services.example.com")).toEqual({
|
||||
hostname: "api.feature-billing.paseo.localhost",
|
||||
port: 3001,
|
||||
});
|
||||
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
|
||||
{
|
||||
hostname: "api.feature-billing.paseo.localhost",
|
||||
publicHostname: "api--feature-billing--paseo.services.example.com",
|
||||
publicBaseUrl: "https://services.example.com:8443",
|
||||
port: 3001,
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "paseo",
|
||||
scriptName: "api",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("updates all services for a workspace when multiple routes are registered", () => {
|
||||
const routeStore = new ScriptRouteStore();
|
||||
registerRoute(routeStore, {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Logger } from "pino";
|
||||
import { buildScriptHostname } from "../utils/script-hostname.js";
|
||||
import { buildPublicScriptHostname, buildScriptHostname } from "../utils/script-hostname.js";
|
||||
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js";
|
||||
|
||||
interface BranchChangeRouteHandlerOptions {
|
||||
@@ -11,6 +11,7 @@ interface BranchChangeRouteHandlerOptions {
|
||||
interface RouteHostnameUpdate {
|
||||
oldHostname: string;
|
||||
newHostname: string;
|
||||
newPublicHostname: string | null;
|
||||
route: ScriptRouteEntry;
|
||||
}
|
||||
|
||||
@@ -31,10 +32,19 @@ export function createBranchChangeRouteHandler(
|
||||
branchName: newBranch,
|
||||
scriptName: route.scriptName,
|
||||
});
|
||||
if (newHostname !== route.hostname) {
|
||||
const newPublicHostname = route.publicBaseUrl
|
||||
? buildPublicScriptHostname({
|
||||
projectSlug: route.projectSlug,
|
||||
branchName: newBranch,
|
||||
scriptName: route.scriptName,
|
||||
publicBaseUrl: route.publicBaseUrl,
|
||||
})
|
||||
: null;
|
||||
if (newHostname !== route.hostname || newPublicHostname !== (route.publicHostname ?? null)) {
|
||||
updates.push({
|
||||
oldHostname: route.hostname,
|
||||
newHostname,
|
||||
newPublicHostname,
|
||||
route,
|
||||
});
|
||||
}
|
||||
@@ -44,10 +54,12 @@ export function createBranchChangeRouteHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
for (const { oldHostname, newHostname, route } of updates) {
|
||||
for (const { oldHostname, newHostname, newPublicHostname, route } of updates) {
|
||||
options.routeStore.removeRoute(oldHostname);
|
||||
options.routeStore.registerRoute({
|
||||
hostname: newHostname,
|
||||
publicHostname: newPublicHostname,
|
||||
publicBaseUrl: route.publicBaseUrl ?? null,
|
||||
port: route.port,
|
||||
workspaceId: route.workspaceId,
|
||||
projectSlug: route.projectSlug,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
WorkspaceScriptPayload,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
|
||||
import { buildScriptHostname } from "../utils/script-hostname.js";
|
||||
import { buildPublicScriptProxyUrl, buildScriptHostname } from "../utils/script-hostname.js";
|
||||
import { getScriptConfigs, isServiceScript, readPaseoConfig } from "../utils/worktree.js";
|
||||
import { deriveProjectSlug } from "./workspace-git-metadata.js";
|
||||
import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.js";
|
||||
@@ -23,6 +23,7 @@ interface BuildWorkspaceScriptPayloadsOptions {
|
||||
routeStore: ScriptRouteStore;
|
||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||
daemonPort: number | null;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
gitMetadata?: {
|
||||
projectSlug: string;
|
||||
currentBranch: string | null;
|
||||
@@ -52,11 +53,26 @@ function resolveDaemonPort(daemonPort: number | null | (() => number | null)): n
|
||||
return daemonPort;
|
||||
}
|
||||
|
||||
function toServiceProxyUrl(hostname: string, daemonPort: number | null): string | null {
|
||||
if (daemonPort === null) {
|
||||
function toServiceProxyUrl(params: {
|
||||
hostname: string;
|
||||
daemonPort: number | null;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
projectSlug: string;
|
||||
branchName: string | null;
|
||||
scriptName: string;
|
||||
}): string | null {
|
||||
if (params.serviceProxyPublicBaseUrl) {
|
||||
return buildPublicScriptProxyUrl({
|
||||
projectSlug: params.projectSlug,
|
||||
branchName: params.branchName,
|
||||
scriptName: params.scriptName,
|
||||
publicBaseUrl: params.serviceProxyPublicBaseUrl,
|
||||
});
|
||||
}
|
||||
if (params.daemonPort === null) {
|
||||
return null;
|
||||
}
|
||||
return `http://${hostname}:${daemonPort}`;
|
||||
return `http://${params.hostname}:${params.daemonPort}`;
|
||||
}
|
||||
|
||||
function toWireHealth(health: ScriptHealthState | null): WorkspaceScriptPayload["health"] {
|
||||
@@ -82,6 +98,7 @@ interface BuildPayloadContext {
|
||||
projectSlug: string;
|
||||
branchName: string | null;
|
||||
daemonPort: number | null;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
resolveHealth?: (hostname: string) => ScriptHealthState | null;
|
||||
}
|
||||
|
||||
@@ -110,7 +127,17 @@ function buildConfiguredScriptPayload(
|
||||
type,
|
||||
hostname,
|
||||
port: type === "service" ? (routeEntry?.port ?? configuredPort) : null,
|
||||
proxyUrl: type === "service" ? toServiceProxyUrl(hostname, ctx.daemonPort) : null,
|
||||
proxyUrl:
|
||||
type === "service"
|
||||
? toServiceProxyUrl({
|
||||
hostname,
|
||||
daemonPort: ctx.daemonPort,
|
||||
serviceProxyPublicBaseUrl: ctx.serviceProxyPublicBaseUrl,
|
||||
projectSlug: ctx.projectSlug,
|
||||
branchName: ctx.branchName,
|
||||
scriptName,
|
||||
})
|
||||
: null,
|
||||
lifecycle: runtimeEntry?.lifecycle ?? "stopped",
|
||||
health: type === "service" ? toWireHealth(ctx.resolveHealth?.(hostname) ?? null) : null,
|
||||
exitCode: runtimeEntry?.exitCode ?? null,
|
||||
@@ -138,7 +165,17 @@ function buildOrphanRuntimePayload(
|
||||
type,
|
||||
hostname,
|
||||
port: type === "service" ? (routeEntry?.port ?? null) : null,
|
||||
proxyUrl: type === "service" ? toServiceProxyUrl(hostname, ctx.daemonPort) : null,
|
||||
proxyUrl:
|
||||
type === "service"
|
||||
? toServiceProxyUrl({
|
||||
hostname,
|
||||
daemonPort: ctx.daemonPort,
|
||||
serviceProxyPublicBaseUrl: ctx.serviceProxyPublicBaseUrl,
|
||||
projectSlug: ctx.projectSlug,
|
||||
branchName: ctx.branchName,
|
||||
scriptName: runtimeEntry.scriptName,
|
||||
})
|
||||
: null,
|
||||
lifecycle: runtimeEntry.lifecycle,
|
||||
health:
|
||||
type === "service" && routeEntry ? toWireHealth(ctx.resolveHealth?.(hostname) ?? null) : null,
|
||||
@@ -170,6 +207,7 @@ export function buildWorkspaceScriptPayloads(
|
||||
projectSlug,
|
||||
branchName,
|
||||
daemonPort: options.daemonPort,
|
||||
serviceProxyPublicBaseUrl: options.serviceProxyPublicBaseUrl,
|
||||
resolveHealth: options.resolveHealth,
|
||||
};
|
||||
|
||||
@@ -210,6 +248,7 @@ export function createScriptStatusEmitter({
|
||||
routeStore,
|
||||
runtimeStore,
|
||||
daemonPort,
|
||||
serviceProxyPublicBaseUrl,
|
||||
resolveWorkspaceDirectory,
|
||||
logger,
|
||||
}: {
|
||||
@@ -217,6 +256,7 @@ export function createScriptStatusEmitter({
|
||||
routeStore: ScriptRouteStore;
|
||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||
daemonPort: number | null | (() => number | null);
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
resolveWorkspaceDirectory: (workspaceId: string) => string | null | Promise<string | null>;
|
||||
logger: Logger;
|
||||
}): (workspaceId: string, scripts: ScriptHealthEntry[]) => void {
|
||||
@@ -239,6 +279,7 @@ export function createScriptStatusEmitter({
|
||||
routeStore,
|
||||
runtimeStore,
|
||||
daemonPort: resolvedDaemonPort,
|
||||
serviceProxyPublicBaseUrl,
|
||||
resolveHealth: (hostname) => scriptHealthByHostname.get(hostname) ?? null,
|
||||
});
|
||||
|
||||
|
||||
@@ -598,6 +598,7 @@ export interface SessionOptions {
|
||||
) => void;
|
||||
getDaemonTcpPort?: () => number | null;
|
||||
getDaemonTcpHost?: () => string | null;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null;
|
||||
voice?: {
|
||||
turnDetection?: Resolvable<TurnDetectionProvider | null>;
|
||||
@@ -811,6 +812,7 @@ export class Session {
|
||||
) => void;
|
||||
private readonly getDaemonTcpPort: (() => number | null) | null;
|
||||
private readonly getDaemonTcpHost: (() => string | null) | null;
|
||||
private readonly serviceProxyPublicBaseUrl: string | null;
|
||||
private readonly resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null;
|
||||
private readonly terminalController: TerminalSessionController;
|
||||
private inflightRequests = 0;
|
||||
@@ -885,6 +887,7 @@ export class Session {
|
||||
onBranchChanged,
|
||||
getDaemonTcpPort,
|
||||
getDaemonTcpHost,
|
||||
serviceProxyPublicBaseUrl,
|
||||
resolveScriptHealth,
|
||||
voice,
|
||||
voiceBridge,
|
||||
@@ -969,6 +972,7 @@ export class Session {
|
||||
this.onBranchChanged = onBranchChanged;
|
||||
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
||||
this.getDaemonTcpHost = getDaemonTcpHost ?? null;
|
||||
this.serviceProxyPublicBaseUrl = serviceProxyPublicBaseUrl ?? null;
|
||||
this.resolveScriptHealth = resolveScriptHealth ?? null;
|
||||
this.sttLanguage = sttLanguage ?? "en";
|
||||
this.subscribeToOptionalManagers();
|
||||
@@ -6299,6 +6303,7 @@ export class Session {
|
||||
routeStore: this.scriptRouteStore,
|
||||
runtimeStore: this.scriptRuntimeStore,
|
||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
||||
gitMetadata: this.resolveWorkspaceScriptGitMetadata(workspace.cwd),
|
||||
resolveHealth: this.resolveScriptHealth ?? undefined,
|
||||
})
|
||||
@@ -7129,6 +7134,7 @@ export class Session {
|
||||
routeStore: this.scriptRouteStore,
|
||||
runtimeStore: this.scriptRuntimeStore,
|
||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
||||
gitMetadata: this.resolveWorkspaceScriptGitMetadata(workspaceDirectory),
|
||||
resolveHealth: this.resolveScriptHealth ?? undefined,
|
||||
});
|
||||
@@ -7194,6 +7200,7 @@ export class Session {
|
||||
scriptName: request.scriptName,
|
||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||
daemonListenHost: this.getDaemonTcpHost?.() ?? null,
|
||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
||||
routeStore: this.scriptRouteStore,
|
||||
runtimeStore: this.scriptRuntimeStore,
|
||||
terminalManager: this.terminalManager,
|
||||
@@ -7345,6 +7352,7 @@ export class Session {
|
||||
scriptRuntimeStore: this.scriptRuntimeStore,
|
||||
getDaemonTcpPort: this.getDaemonTcpPort,
|
||||
getDaemonTcpHost: this.getDaemonTcpHost,
|
||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
||||
onScriptsChanged: (workspaceId, workspaceDirectory) => {
|
||||
this.emitWorkspaceScriptStatusUpdate(workspaceId, workspaceDirectory);
|
||||
},
|
||||
|
||||
@@ -361,6 +361,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private scriptRuntimeStore!: WorkspaceScriptRuntimeStore | null;
|
||||
private getDaemonTcpPort!: (() => number | null) | null;
|
||||
private getDaemonTcpHost!: (() => string | null) | null;
|
||||
private serviceProxyPublicBaseUrl!: string | null;
|
||||
private resolveScriptHealth!: ((hostname: string) => ScriptHealthState | null) | null;
|
||||
private dictation!: {
|
||||
finalTimeoutMs?: number;
|
||||
@@ -429,6 +430,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
publicUseTls: boolean;
|
||||
};
|
||||
},
|
||||
serviceProxyPublicBaseUrl?: string | null,
|
||||
) {
|
||||
this.logger = logger.child({ module: "websocket-server" });
|
||||
this.serverId = serverId;
|
||||
@@ -468,6 +470,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
onBranchChanged,
|
||||
getDaemonTcpPort,
|
||||
getDaemonTcpHost,
|
||||
serviceProxyPublicBaseUrl,
|
||||
resolveScriptHealth,
|
||||
});
|
||||
if (!providerSnapshotManager) {
|
||||
@@ -518,6 +521,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
| undefined;
|
||||
getDaemonTcpPort: (() => number | null) | undefined;
|
||||
getDaemonTcpHost: (() => string | null) | undefined;
|
||||
serviceProxyPublicBaseUrl: string | null | undefined;
|
||||
resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | undefined;
|
||||
}): void {
|
||||
this.speech = params.speech ?? null;
|
||||
@@ -529,6 +533,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.onBranchChanged = params.onBranchChanged ?? null;
|
||||
this.getDaemonTcpPort = params.getDaemonTcpPort ?? null;
|
||||
this.getDaemonTcpHost = params.getDaemonTcpHost ?? null;
|
||||
this.serviceProxyPublicBaseUrl = params.serviceProxyPublicBaseUrl ?? null;
|
||||
this.resolveScriptHealth = params.resolveScriptHealth ?? null;
|
||||
}
|
||||
|
||||
@@ -885,6 +890,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
onBranchChanged: this.onBranchChanged ?? undefined,
|
||||
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
|
||||
getDaemonTcpHost: this.getDaemonTcpHost ?? undefined,
|
||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
||||
resolveScriptHealth: this.resolveScriptHealth ?? undefined,
|
||||
voice: {
|
||||
turnDetection: () => this.speech?.resolveTurnDetection() ?? null,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildScriptHostname } from "../utils/script-hostname.js";
|
||||
import { buildPublicScriptProxyUrl, buildScriptHostname } from "../utils/script-hostname.js";
|
||||
|
||||
export interface WorkspaceServicePeer {
|
||||
scriptName: string;
|
||||
@@ -11,6 +11,7 @@ export interface BuildWorkspaceServiceEnvOptions {
|
||||
branchName: string | null;
|
||||
daemonPort: number | null | undefined;
|
||||
daemonListenHost: string | null | undefined;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
peers: readonly WorkspaceServicePeer[];
|
||||
}
|
||||
|
||||
@@ -37,26 +38,30 @@ export function buildWorkspaceServiceEnv(
|
||||
PASEO_PORT: String(selfPeer.port),
|
||||
};
|
||||
|
||||
if (options.daemonPort !== null && options.daemonPort !== undefined) {
|
||||
env.PASEO_URL = buildServiceProxyUrl({
|
||||
projectSlug: options.projectSlug,
|
||||
branchName: options.branchName,
|
||||
scriptName: options.scriptName,
|
||||
daemonPort: options.daemonPort,
|
||||
});
|
||||
const selfProxyUrl = buildServiceProxyUrl({
|
||||
projectSlug: options.projectSlug,
|
||||
branchName: options.branchName,
|
||||
scriptName: options.scriptName,
|
||||
daemonPort: options.daemonPort,
|
||||
serviceProxyPublicBaseUrl: options.serviceProxyPublicBaseUrl,
|
||||
});
|
||||
if (selfProxyUrl) {
|
||||
env.PASEO_URL = selfProxyUrl;
|
||||
}
|
||||
|
||||
for (const peer of options.peers) {
|
||||
const envName = normalizeServiceEnvName(peer.scriptName);
|
||||
env[`PASEO_SERVICE_${envName}_PORT`] = String(peer.port);
|
||||
|
||||
if (options.daemonPort !== null && options.daemonPort !== undefined) {
|
||||
env[`PASEO_SERVICE_${envName}_URL`] = buildServiceProxyUrl({
|
||||
projectSlug: options.projectSlug,
|
||||
branchName: options.branchName,
|
||||
scriptName: peer.scriptName,
|
||||
daemonPort: options.daemonPort,
|
||||
});
|
||||
const peerProxyUrl = buildServiceProxyUrl({
|
||||
projectSlug: options.projectSlug,
|
||||
branchName: options.branchName,
|
||||
scriptName: peer.scriptName,
|
||||
daemonPort: options.daemonPort,
|
||||
serviceProxyPublicBaseUrl: options.serviceProxyPublicBaseUrl,
|
||||
});
|
||||
if (peerProxyUrl) {
|
||||
env[`PASEO_SERVICE_${envName}_URL`] = peerProxyUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,10 +76,24 @@ interface BuildServiceProxyUrlOptions {
|
||||
projectSlug: string;
|
||||
branchName: string | null;
|
||||
scriptName: string;
|
||||
daemonPort: number;
|
||||
daemonPort: number | null | undefined;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
}
|
||||
|
||||
function buildServiceProxyUrl(options: BuildServiceProxyUrlOptions): string {
|
||||
function buildServiceProxyUrl(options: BuildServiceProxyUrlOptions): string | null {
|
||||
if (options.serviceProxyPublicBaseUrl) {
|
||||
return buildPublicScriptProxyUrl({
|
||||
projectSlug: options.projectSlug,
|
||||
branchName: options.branchName,
|
||||
scriptName: options.scriptName,
|
||||
publicBaseUrl: options.serviceProxyPublicBaseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.daemonPort === null || options.daemonPort === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = buildScriptHostname({
|
||||
projectSlug: options.projectSlug,
|
||||
branchName: options.branchName,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import type { Logger } from "pino";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { TerminalSession } from "../terminal/terminal.js";
|
||||
import { buildScriptHostname } from "../utils/script-hostname.js";
|
||||
import { buildPublicScriptHostname, buildScriptHostname } from "../utils/script-hostname.js";
|
||||
import {
|
||||
getScriptConfigs,
|
||||
getWorktreeTerminalSpecs,
|
||||
@@ -699,6 +699,7 @@ interface SpawnWorkspaceScriptOptions {
|
||||
scriptName: string;
|
||||
daemonPort?: number | null;
|
||||
daemonListenHost?: string | null;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
routeStore: ScriptRouteStore;
|
||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||
terminalManager: TerminalManager;
|
||||
@@ -721,6 +722,7 @@ async function setupServiceScriptRoute(params: {
|
||||
workspaceId: string;
|
||||
daemonPort: number | null | undefined;
|
||||
daemonListenHost: string | null | undefined;
|
||||
serviceProxyPublicBaseUrl: string | null | undefined;
|
||||
existingRuntimeEntry: ReturnType<WorkspaceScriptRuntimeStore["get"]>;
|
||||
routeStore: ScriptRouteStore;
|
||||
}): Promise<ServiceScriptSetupResult> {
|
||||
@@ -733,10 +735,19 @@ async function setupServiceScriptRoute(params: {
|
||||
workspaceId,
|
||||
daemonPort,
|
||||
daemonListenHost,
|
||||
serviceProxyPublicBaseUrl,
|
||||
existingRuntimeEntry,
|
||||
routeStore,
|
||||
} = params;
|
||||
const hostname = buildScriptHostname({ projectSlug, branchName, scriptName });
|
||||
const publicHostname = serviceProxyPublicBaseUrl
|
||||
? buildPublicScriptHostname({
|
||||
projectSlug,
|
||||
branchName,
|
||||
scriptName,
|
||||
publicBaseUrl: serviceProxyPublicBaseUrl,
|
||||
})
|
||||
: null;
|
||||
|
||||
const serviceDeclarations: Array<{ scriptName: string; port?: number }> = [];
|
||||
for (const [configuredScriptName, scriptConfig] of scriptConfigs) {
|
||||
@@ -779,11 +790,14 @@ async function setupServiceScriptRoute(params: {
|
||||
branchName,
|
||||
daemonPort,
|
||||
daemonListenHost,
|
||||
serviceProxyPublicBaseUrl,
|
||||
peers,
|
||||
});
|
||||
|
||||
routeStore.registerRoute({
|
||||
hostname,
|
||||
publicHostname,
|
||||
publicBaseUrl: serviceProxyPublicBaseUrl ?? null,
|
||||
port,
|
||||
workspaceId,
|
||||
projectSlug,
|
||||
@@ -828,6 +842,7 @@ export async function spawnWorkspaceScript(
|
||||
scriptName,
|
||||
daemonPort,
|
||||
daemonListenHost,
|
||||
serviceProxyPublicBaseUrl,
|
||||
routeStore,
|
||||
runtimeStore,
|
||||
terminalManager,
|
||||
@@ -869,6 +884,7 @@ export async function spawnWorkspaceScript(
|
||||
workspaceId,
|
||||
daemonPort,
|
||||
daemonListenHost,
|
||||
serviceProxyPublicBaseUrl,
|
||||
existingRuntimeEntry,
|
||||
routeStore,
|
||||
});
|
||||
|
||||
@@ -107,6 +107,7 @@ interface CreatePaseoWorktreeInBackgroundDependencies {
|
||||
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
|
||||
getDaemonTcpPort: (() => number | null) | null;
|
||||
getDaemonTcpHost: (() => string | null) | null;
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
onScriptsChanged: ((workspaceId: string, workspaceDirectory: string) => void) | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -93,6 +93,22 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"serviceProxy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"listen": {
|
||||
"type": "string"
|
||||
},
|
||||
"publicBaseUrl": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"auth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user