Unify workspace service URLs (#1288)

* Unify workspace service proxying

* Clean up service proxy review issues
This commit is contained in:
Mohamed Boudra
2026-06-02 19:49:58 +08:00
committed by GitHub
parent 5666b3014a
commit 4f3116d28a
38 changed files with 2664 additions and 1538 deletions
+3 -1
View File
@@ -158,12 +158,14 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
| Variable | Value |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
| `PASEO_SERVICE_<NAME>_URL` | Proxied URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
| `HOST` | Bind host for the service process. |
Service proxy hostnames use the double-dash shape: `web--feature-auth--project.localhost` or, on the default branch, `web--project.localhost`. Optional public aliases use the same leftmost label under the configured public base host.
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
`PORT` is not injected by default. If a framework requires `PORT`, set it in the command:
+17 -15
View File
@@ -1,6 +1,6 @@
# 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.
Paseo proxies HTTP traffic to services running inside your workspaces. Localhost service URLs are always enabled; optional public aliases and a separate service-only listener can be layered on through config.
## How it works
@@ -9,22 +9,22 @@ When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a loc
The generated hostname is built from the script name, branch, and project:
```
<script>--<branch>--<project>.<base-domain>
<script>--<branch>--<project>.localhost
```
If the branch is `main` or `master`, the branch segment is omitted:
```
<script>--<project>.<base-domain>
<script>--<project>.localhost
```
**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
dev--feature-auth--miniweb.localhost
```
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.
Local and public routes use one combined leftmost label (`script--branch--project`). This keeps the hostname compatible with normal single-level wildcard DNS and TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it with a deterministic hash suffix to avoid collisions.
## Configuration
@@ -35,7 +35,6 @@ Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
"version": 1,
"daemon": {
"serviceProxy": {
"enabled": true,
"listen": "0.0.0.0:8080",
"publicBaseUrl": "https://paseoapps.my.domain.com"
}
@@ -44,10 +43,11 @@ Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
```
| 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. |
| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `listen` | No | Starts a separate service-only listener at this address. If omitted, services are still reachable on the daemon listener via localhost hosts. |
| `publicBaseUrl` | No | Adds public service host aliases and public service links. If omitted, links use localhost addresses only. |
`enabled` is accepted for old configs but no longer enables a mode. `enabled: false` suppresses optional `listen`/`publicBaseUrl` layers only; localhost service proxying remains always enabled.
## DNS and reverse proxy setup
@@ -63,7 +63,9 @@ For generated URLs to be reachable, you need wildcard DNS pointing to the machin
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.
3. If you put a reverse proxy (nginx, Caddy, Traefik, etc.) in front of Paseo, point it at either the daemon listener or the optional service-only listener and 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.
Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services.
Nginx example:
@@ -84,7 +86,7 @@ For generated URLs to be reachable, you need wildcard DNS pointing to the machin
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 |
| ------------------------------------- | ------------------------------------------------------------------------- |
| `PASEO_SERVICE_PROXY_ENABLED` | Compatibility shim; `false` suppresses optional public/listen layers only |
| `PASEO_SERVICE_PROXY_LISTEN` | Starts the optional service-only listener, e.g. `0.0.0.0:8080` |
| `PASEO_SERVICE_PROXY_PUBLIC_BASE_URL` | Adds public service aliases and links |
@@ -6,9 +6,11 @@ import { resolveWorkspaceScriptLink } from "./workspace-script-links";
const runningService: WorkspaceScriptPayload = {
scriptName: "web",
type: "service",
hostname: "web.feature.paseo.localhost",
hostname: "web--feature--paseo.localhost",
port: 3000,
proxyUrl: "http://web.feature.paseo.localhost:6767",
localProxyUrl: "http://web--feature--paseo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://web--feature--paseo.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
@@ -27,8 +29,8 @@ describe("resolveWorkspaceScriptLink", () => {
expect(
resolveLink({ type: "directTcp", endpoint: "localhost:6767", display: "localhost:6767" }),
).toEqual({
openUrl: "http://web.feature.paseo.localhost:6767",
labelUrl: "http://web.feature.paseo.localhost:6767",
openUrl: "http://web--feature--paseo.localhost:6767",
labelUrl: "http://web--feature--paseo.localhost:6767",
});
});
@@ -36,8 +38,8 @@ describe("resolveWorkspaceScriptLink", () => {
expect(
resolveLink({ type: "directSocket", endpoint: "/tmp/paseo.sock", display: "socket" }),
).toEqual({
openUrl: "http://web.feature.paseo.localhost:6767",
labelUrl: "http://web.feature.paseo.localhost:6767",
openUrl: "http://web--feature--paseo.localhost:6767",
labelUrl: "http://web--feature--paseo.localhost:6767",
});
});
@@ -59,7 +61,114 @@ describe("resolveWorkspaceScriptLink", () => {
resolveLink({ type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" }),
).toEqual({
openUrl: null,
labelUrl: "http://web.feature.paseo.localhost:6767",
labelUrl: "http://web--feature--paseo.localhost:6767",
});
});
it("opens the public URL over relay when one is provided", () => {
expect(
resolveWorkspaceScriptLink({
script: {
...runningService,
publicProxyUrl: "https://web--feature--paseo.services.example.com",
proxyUrl: "https://web--feature--paseo.services.example.com",
},
activeConnection: { type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" },
}),
).toEqual({
openUrl: "https://web--feature--paseo.services.example.com",
labelUrl: "https://web--feature--paseo.services.example.com",
});
});
it("uses local URL for direct loopback even when public URL exists", () => {
expect(
resolveWorkspaceScriptLink({
script: {
...runningService,
publicProxyUrl: "https://web--feature--paseo.services.example.com",
proxyUrl: "https://web--feature--paseo.services.example.com",
},
activeConnection: { type: "directTcp", endpoint: "127.0.0.1:6767", display: "localhost" },
}),
).toEqual({
openUrl: "http://web--feature--paseo.localhost:6767",
labelUrl: "http://web--feature--paseo.localhost:6767",
});
});
it("uses local URL for direct socket and pipe even when public URL exists", () => {
expect(
resolveWorkspaceScriptLink({
script: {
...runningService,
publicProxyUrl: "https://web--feature--paseo.services.example.com",
proxyUrl: "https://web--feature--paseo.services.example.com",
},
activeConnection: { type: "directPipe", endpoint: "paseo", display: "pipe" },
}),
).toEqual({
openUrl: "http://web--feature--paseo.localhost:6767",
labelUrl: "http://web--feature--paseo.localhost:6767",
});
});
it("uses public URL for direct remote TCP when split URLs exist", () => {
expect(
resolveWorkspaceScriptLink({
script: {
...runningService,
publicProxyUrl: "https://web--feature--paseo.services.example.com",
proxyUrl: "https://web--feature--paseo.services.example.com",
},
activeConnection: {
type: "directTcp",
endpoint: "mac-mini.tail123.ts.net:6767",
display: "remote",
},
}),
).toEqual({
openUrl: "https://web--feature--paseo.services.example.com",
labelUrl: "https://web--feature--paseo.services.example.com",
});
});
it("keeps old daemon local-only proxyUrl payloads working", () => {
const {
localProxyUrl: _localProxyUrl,
publicProxyUrl: _publicProxyUrl,
...oldPayload
} = runningService;
expect(
resolveWorkspaceScriptLink({
script: oldPayload,
activeConnection: { type: "directTcp", endpoint: "localhost:6767", display: "localhost" },
}),
).toEqual({
openUrl: "http://web--feature--paseo.localhost:6767",
labelUrl: "http://web--feature--paseo.localhost:6767",
});
});
it("keeps old daemon public proxyUrl payloads working over relay", () => {
const {
localProxyUrl: _localProxyUrl,
publicProxyUrl: _publicProxyUrl,
...oldPayload
} = {
...runningService,
proxyUrl: "https://web--feature--paseo.services.example.com",
};
expect(
resolveWorkspaceScriptLink({
script: oldPayload,
activeConnection: { type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" },
}),
).toEqual({
openUrl: "https://web--feature--paseo.services.example.com",
labelUrl: "https://web--feature--paseo.services.example.com",
});
});
});
@@ -50,27 +50,33 @@ export function resolveWorkspaceScriptLink(input: {
return { openUrl: null, labelUrl: script.proxyUrl };
}
const proxyUrlIsLocalOnly = isLocalOnlyUrl(script.proxyUrl);
if (!proxyUrlIsLocalOnly) {
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl };
}
const localProxyUrl = script.localProxyUrl ?? script.proxyUrl;
const publicProxyUrl =
script.publicProxyUrl ?? (!isLocalOnlyUrl(script.proxyUrl) ? script.proxyUrl : null);
const preferredProxyUrl = publicProxyUrl ?? localProxyUrl ?? script.proxyUrl;
if (activeConnection.type === "relay") {
return { openUrl: null, labelUrl: script.proxyUrl };
return {
openUrl: publicProxyUrl,
labelUrl: publicProxyUrl ?? localProxyUrl ?? script.proxyUrl,
};
}
if (activeConnection.type === "directSocket" || activeConnection.type === "directPipe") {
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl };
return { openUrl: localProxyUrl, labelUrl: localProxyUrl };
}
try {
const { host } = parseHostPort(activeConnection.endpoint);
if (isLoopbackHost(host)) {
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl };
return { openUrl: localProxyUrl, labelUrl: localProxyUrl };
}
} catch {
return { openUrl: null, labelUrl: script.proxyUrl };
return { openUrl: null, labelUrl: preferredProxyUrl };
}
if (publicProxyUrl) {
return { openUrl: publicProxyUrl, labelUrl: publicProxyUrl };
}
if (script.port === null) {
@@ -80,6 +86,6 @@ export function resolveWorkspaceScriptLink(input: {
const directUrl = buildDirectServiceUrl(activeConnection.endpoint, script.port);
return {
openUrl: directUrl,
labelUrl: directUrl ?? script.proxyUrl,
labelUrl: directUrl ?? preferredProxyUrl,
};
}
+2
View File
@@ -2325,6 +2325,8 @@ export const WorkspaceScriptPayloadSchema = z.object({
type: z.enum(["script", "service"]).optional().default("service"),
hostname: z.string(),
port: z.number().int().positive().nullable(),
localProxyUrl: z.string().nullable().optional(),
publicProxyUrl: z.string().nullable().optional(),
proxyUrl: z.string().nullable().optional().default(null),
lifecycle: WorkspaceScriptLifecycleSchema,
health: WorkspaceScriptHealthSchema.nullable(),
@@ -5,6 +5,7 @@ import {
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
WorkspaceDescriptorPayloadSchema,
WorkspaceScriptPayloadSchema,
} from "./messages.js";
describe("workspace message schemas", () => {
@@ -485,6 +486,53 @@ describe("workspace message schemas", () => {
});
});
test("parses workspace service payloads from old daemons without split proxy URLs", () => {
const parsed = WorkspaceScriptPayloadSchema.parse({
scriptName: "web",
type: "service",
hostname: "web--repo.localhost",
port: 3000,
proxyUrl: "http://web--repo.localhost:6767",
lifecycle: "running",
health: "healthy",
});
expect(parsed.localProxyUrl).toBeUndefined();
expect(parsed.publicProxyUrl).toBeUndefined();
expect(parsed.proxyUrl).toBe("http://web--repo.localhost:6767");
});
test("parses workspace service payloads with split local and public proxy URLs", () => {
const parsed = WorkspaceScriptPayloadSchema.parse({
scriptName: "web",
type: "service",
hostname: "web--repo.localhost",
port: 3000,
localProxyUrl: "http://web--repo.localhost:6767",
publicProxyUrl: "https://web--repo.services.example.com",
proxyUrl: "https://web--repo.services.example.com",
lifecycle: "running",
health: "healthy",
});
expect(parsed.localProxyUrl).toBe("http://web--repo.localhost:6767");
expect(parsed.publicProxyUrl).toBe("https://web--repo.services.example.com");
expect(parsed.proxyUrl).toBe("https://web--repo.services.example.com");
});
test("defaults omitted workspace script proxyUrl to null", () => {
const parsed = WorkspaceScriptPayloadSchema.parse({
scriptName: "typecheck",
type: "script",
hostname: "typecheck",
port: null,
lifecycle: "stopped",
health: null,
});
expect(parsed.proxyUrl).toBeNull();
});
test("parses workspace_setup_progress payload", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_setup_progress",
@@ -1,14 +1,18 @@
import os from "node:os";
import http from "node:http";
import path from "node:path";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
import { hashDaemonPassword } from "./auth.js";
import { generateLocalPairingOffer } from "./pairing-offer.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
import { isPlatform } from "../test-utils/platform.js";
import { findFreePort } from "./service-proxy.js";
describe("paseo daemon bootstrap", () => {
afterEach(() => {
@@ -41,6 +45,253 @@ describe("paseo daemon bootstrap", () => {
}
});
function httpGetWithHost(port: number, host: string, requestPath: string): Promise<Response> {
return new Promise((resolve, reject) => {
const req = http.get(
{ hostname: "127.0.0.1", port, path: requestPath, headers: { host } },
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", () => {
resolve(
new Response(Buffer.concat(chunks), {
status: res.statusCode ?? 0,
headers: res.headers as HeadersInit,
}),
);
});
},
);
req.on("error", reject);
});
}
test("proxies registered service hosts before daemon auth while daemon APIs stay protected", async () => {
const upstream = http.createServer((_req, res) => {
res.writeHead(200, { "content-type": "text/plain" });
res.end("service-ok");
});
await new Promise<void>((resolve) => upstream.listen(0, "127.0.0.1", resolve));
const address = upstream.address();
if (!address || typeof address === "string") {
throw new Error("Expected upstream TCP address");
}
const daemonHandle = await createTestPaseoDaemon({
auth: { password: hashDaemonPassword("secret") },
});
try {
daemonHandle.daemon.serviceProxy.registerWorkspaceService({
workspaceId: "workspace-service-auth",
projectSlug: "repo",
branchName: "main",
scriptName: "web",
port: address.port,
});
const serviceResponse = await httpGetWithHost(
daemonHandle.port,
`web--repo.localhost:${daemonHandle.port}`,
"/",
);
expect(serviceResponse.status).toBe(200);
expect(await serviceResponse.text()).toBe("service-ok");
const daemonResponse = await httpGetWithHost(
daemonHandle.port,
`daemon.localhost:${daemonHandle.port}`,
"/api/status",
);
expect(daemonResponse.status).toBe(401);
} finally {
await daemonHandle.close();
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
});
test("configured public service namespace misses never reach daemon APIs", async () => {
const daemonHandle = await createTestPaseoDaemon({
serviceProxy: {
publicBaseUrl: "https://services.example.com",
standaloneListen: null,
},
});
try {
const response = await httpGetWithHost(
daemonHandle.port,
`missing.services.example.com:${daemonHandle.port}`,
"/api/status",
);
expect(response.status).toBe(404);
expect(await response.text()).toBe("404 Not Found");
} finally {
await daemonHandle.close();
}
});
test("rolls back daemon listener when standalone service proxy startup fails", async () => {
const occupiedServer = http.createServer((_req, res) => {
res.end("occupied");
});
await new Promise<void>((resolve) => occupiedServer.listen(0, "127.0.0.1", resolve));
const address = occupiedServer.address();
if (!address || typeof address === "string") {
throw new Error("Expected occupied TCP address");
}
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-standalone-rollback-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
await mkdir(paseoHome, { recursive: true });
const config: PaseoDaemonConfig = {
listen: "127.0.0.1:0",
paseoHome,
corsAllowedOrigins: [],
hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
relayEnabled: false,
appBaseUrl: "https://app.paseo.sh",
openai: undefined,
speech: undefined,
serviceProxy: {
standaloneListen: `127.0.0.1:${address.port}`,
},
};
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
try {
await expect(daemon.start()).rejects.toThrow();
await expect(fetch(`http://127.0.0.1:${daemon.port}/api/health`)).rejects.toThrow();
} finally {
await daemon.stop().catch(() => undefined);
await new Promise<void>((resolve) => occupiedServer.close(() => resolve()));
await rm(paseoHomeRoot, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
}
});
test("local service namespace misses never reach daemon APIs", async () => {
const daemonHandle = await createTestPaseoDaemon({
auth: { password: hashDaemonPassword("secret") },
});
try {
const response = await httpGetWithHost(
daemonHandle.port,
`missing--repo.localhost:${daemonHandle.port}`,
"/api/status",
);
expect(response.status).toBe(404);
expect(await response.text()).toBe("404 Not Found");
} finally {
await daemonHandle.close();
}
});
test("daemon websocket still upgrades when service proxy upgrade handler is mounted", async () => {
const daemonHandle = await createTestPaseoDaemon();
const ws = new WebSocket(`ws://127.0.0.1:${daemonHandle.port}/ws`);
try {
await new Promise<void>((resolve, reject) => {
ws.once("open", resolve);
ws.once("error", reject);
});
expect(ws.readyState).toBe(WebSocket.OPEN);
} finally {
ws.close();
await daemonHandle.close();
}
});
test("standalone listener exposes services only", async () => {
const standalonePort = await findFreePort();
const upstream = http.createServer((_req, res) => {
res.end("service-ok");
});
await new Promise<void>((resolve) => upstream.listen(0, "127.0.0.1", resolve));
const upstreamAddress = upstream.address();
if (!upstreamAddress || typeof upstreamAddress === "string") {
throw new Error("Expected upstream TCP address");
}
const daemonHandle = await createTestPaseoDaemon({
serviceProxy: { standaloneListen: `127.0.0.1:${standalonePort}` },
});
try {
daemonHandle.daemon.serviceProxy.registerWorkspaceService({
workspaceId: "workspace-standalone",
projectSlug: "repo",
branchName: "main",
scriptName: "web",
port: upstreamAddress.port,
});
const serviceResponse = await httpGetWithHost(
standalonePort,
`web--repo.localhost:${standalonePort}`,
"/",
);
expect(serviceResponse.status).toBe(200);
expect(await serviceResponse.text()).toBe("service-ok");
for (const requestPath of ["/api/health", "/ws", "/mcp/agents", "/index.html", "/files/x"]) {
const response = await httpGetWithHost(
standalonePort,
`daemon.localhost:${standalonePort}`,
requestPath,
);
expect(response.status).toBe(404);
}
} finally {
await daemonHandle.close();
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
});
test("rolls back already-open standalone listener when main daemon listen fails", async () => {
const mainPort = await findFreePort();
const standalonePort = await findFreePort();
const occupiedMain = http.createServer((_req, res) => {
res.end("occupied-main");
});
await new Promise<void>((resolve) => occupiedMain.listen(mainPort, "127.0.0.1", resolve));
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
await mkdir(paseoHome, { recursive: true });
const config: PaseoDaemonConfig = {
listen: `127.0.0.1:${mainPort}`,
paseoHome,
corsAllowedOrigins: [],
hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
relayEnabled: false,
appBaseUrl: "https://app.paseo.sh",
openai: undefined,
speech: undefined,
serviceProxy: { standaloneListen: `127.0.0.1:${standalonePort}` },
};
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
try {
await expect(daemon.start()).rejects.toThrow();
await expect(fetch(`http://127.0.0.1:${standalonePort}/api/health`)).rejects.toThrow();
} finally {
await daemon.stop().catch(() => undefined);
await new Promise<void>((resolve) => occupiedMain.close(() => resolve()));
await rm(paseoHomeRoot, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
}
});
test("redacts Agent MCP debug request credentials and bodies", async () => {
const logLines: string[] = [];
const logger = pino(
+51 -99
View File
@@ -126,11 +126,7 @@ import type {
ProviderOverride,
} from "./agent/provider-launch-config.js";
import type { PersistedConfig } from "./persisted-config.js";
import {
ScriptRouteStore,
createScriptProxyMiddleware,
createScriptProxyUpgradeHandler,
} from "./script-proxy.js";
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js";
import { ScriptHealthMonitor } from "./script-health-monitor.js";
import { createScriptStatusEmitter } from "./script-status-projection.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
@@ -250,9 +246,8 @@ export interface PaseoDaemonConfig {
relayUseTls?: boolean;
relayPublicUseTls?: boolean;
serviceProxy?: {
enabled: boolean;
listen: string;
publicBaseUrl: string | null;
standaloneListen: string | null;
};
appBaseUrl?: string;
auth?: DaemonAuthConfig;
@@ -282,7 +277,7 @@ export interface PaseoDaemon {
agentManager: AgentManager;
agentStorage: AgentStorage;
terminalManager: TerminalManager;
scriptRouteStore: ScriptRouteStore;
serviceProxy: ServiceProxySubsystem;
scriptRuntimeStore: WorkspaceScriptRuntimeStore;
start(): Promise<void>;
stop(): Promise<void>;
@@ -336,24 +331,25 @@ export async function createPaseoDaemon(
let boundListenTarget: ListenTarget | null = null;
let workspaceRegistry: FileBackedWorkspaceRegistry | null = null;
const scriptRouteStore = new ScriptRouteStore();
const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
const configuredHostnames = config.hostnames ?? config.allowedHosts;
const serviceProxyPublicBaseUrl =
config.serviceProxy?.enabled && config.serviceProxy.publicBaseUrl
const serviceProxyPublicBaseUrl = config.serviceProxy?.publicBaseUrl
? config.serviceProxy.publicBaseUrl
: null;
const serviceProxy = createServiceProxySubsystem({
logger,
publicBaseUrl: serviceProxyPublicBaseUrl,
});
const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
const configuredHostnames = config.hostnames ?? config.allowedHosts;
let wsServer: VoiceAssistantWebSocketServer | null = null;
let serviceProxyHttpServer: ReturnType<typeof createHTTPServer> | null = null;
let serviceProxyListenTarget: ListenTarget | null = null;
const scriptHealthMonitor = new ScriptHealthMonitor({
routeStore: scriptRouteStore,
serviceProxy,
onChange: createScriptStatusEmitter({
sessions: () =>
wsServer?.listActiveSessions().map((session) => ({
emit: (message) => session.emitServerMessage(message),
})) ?? [],
routeStore: scriptRouteStore,
serviceProxy,
runtimeStore: scriptRuntimeStore,
daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
resolveWorkspaceDirectory: async (workspaceId) =>
@@ -363,13 +359,18 @@ export async function createPaseoDaemon(
}),
});
const handleBranchChange = createBranchChangeRouteHandler({
routeStore: scriptRouteStore,
serviceProxy,
onRoutesChanged: (workspaceId) => {
scriptHealthMonitor.invalidateWorkspace(workspaceId);
},
logger,
});
// Service proxy classifies service hosts before daemon auth/route fallthrough.
// Registered service hosts proxy directly; known service namespaces without a
// route return 404 and never reach daemon APIs.
app.use(serviceProxy.middleware());
// Host allowlist / DNS rebinding protection (vite-like semantics).
// For non-TCP (unix sockets), skip host validation.
if (listenTarget.type === "tcp") {
@@ -419,11 +420,6 @@ export async function createPaseoDaemon(
}),
);
// Script proxy — intercepts requests for registered *.localhost hostnames
// and forwards them to the corresponding local script port. Placed after
// host/CORS/auth checks but before the rest of the routes.
app.use(createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger }));
// Serve static files from public directory
app.use("/public", express.static(staticDir));
@@ -507,34 +503,10 @@ export async function createPaseoDaemon(
// VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that
// script-bound upgrades are forwarded first. The handler is a no-op for
// requests that don't match a registered script route.
const scriptProxyUpgradeHandler = createScriptProxyUpgradeHandler({
routeStore: scriptRouteStore,
logger,
});
httpServer.on("upgrade", scriptProxyUpgradeHandler);
httpServer.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: true }));
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;
if (config.serviceProxy?.standaloneListen) {
serviceProxyListenTarget = parseListenString(config.serviceProxy.standaloneListen);
}
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
@@ -767,7 +739,7 @@ export async function createPaseoDaemon(
sessionLogger: logger,
terminalManager,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
scriptRouteStore,
serviceProxy,
scriptRuntimeStore,
getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
@@ -914,6 +886,23 @@ export async function createPaseoDaemon(
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
const start = async () => {
let mainStarted = false;
try {
if (serviceProxyListenTarget) {
const boundServiceProxyTarget = await serviceProxy.startStandalone({
listenTarget: serviceProxyListenTarget,
});
serviceProxyListenTarget = boundServiceProxyTarget;
logger.info(
{
listen: formatListenTarget(serviceProxyListenTarget),
publicBaseUrl: serviceProxyPublicBaseUrl,
elapsed: elapsed(),
},
"Service proxy listening",
);
}
// Start main HTTP server
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => {
@@ -922,6 +911,7 @@ export async function createPaseoDaemon(
};
const onListening = () => {
httpServer.off("error", onError);
mainStarted = true;
const logAndResolve = async () => {
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
@@ -995,7 +985,7 @@ export async function createPaseoDaemon(
loopService,
scheduleService,
checkoutDiffManager,
scriptRouteStore,
serviceProxy,
scriptRuntimeStore,
handleBranchChange,
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
@@ -1063,45 +1053,18 @@ 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();
scriptHealthMonitor.start();
} catch (error) {
await serviceProxy.stopStandalone().catch(() => undefined);
if (mainStarted) {
httpServer.closeAllConnections();
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
}
throw error;
}
};
const stop = async () => {
@@ -1118,18 +1081,7 @@ 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);
}
}
await serviceProxy.stopStandalone();
// 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
@@ -1152,7 +1104,7 @@ export async function createPaseoDaemon(
agentManager,
agentStorage,
terminalManager,
scriptRouteStore,
serviceProxy,
scriptRuntimeStore,
start,
stop,
@@ -104,9 +104,38 @@ describe("daemon service proxy config", () => {
});
expect(config.serviceProxy).toEqual({
enabled: true,
listen: "127.0.0.1:6868",
publicBaseUrl: "https://env.example.com",
standaloneListen: null,
});
});
test("does not synthesize a standalone service listener from enabled true", async () => {
const home = await createPaseoHome({
version: 1,
daemon: { serviceProxy: { enabled: true } },
});
expect(loadConfig(home, { env: {} }).serviceProxy).toEqual({
publicBaseUrl: null,
standaloneListen: null,
});
});
test("enabled false suppresses optional service proxy layers only", async () => {
const home = await createPaseoHome({
version: 1,
daemon: {
serviceProxy: {
enabled: false,
listen: "127.0.0.1:9999",
publicBaseUrl: "https://persisted.example.com",
},
},
});
expect(loadConfig(home, { env: {} }).serviceProxy).toEqual({
publicBaseUrl: null,
standaloneListen: null,
});
});
+15 -14
View File
@@ -24,7 +24,6 @@ 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) {
@@ -154,9 +153,8 @@ interface ResolvedRelay {
}
interface ResolvedServiceProxy {
enabled: boolean;
listen: string;
publicBaseUrl: string | null;
standaloneListen: string | null;
}
function resolveTlsFromEnv(
@@ -220,21 +218,24 @@ function resolveServiceProxyConfig(
env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>,
): ResolvedServiceProxy {
const publicBaseUrl = resolveServiceProxyPublicBaseUrl(
const enabledShim =
parseBooleanEnv(env.PASEO_SERVICE_PROXY_ENABLED) ?? persisted.daemon?.serviceProxy?.enabled;
// COMPAT(serviceProxyEnabled): added 2026-06-02, remove after 2026-12-02.
// `enabled=false` used to disable the separate service proxy listener. Localhost
// service proxying is now always enabled; this only suppresses optional layers.
const optionalLayersEnabled = enabledShim !== false;
const publicBaseUrl = optionalLayersEnabled
? 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;
)
: null;
const standaloneListen = optionalLayersEnabled
? (env.PASEO_SERVICE_PROXY_LISTEN ?? persisted.daemon?.serviceProxy?.listen ?? null)
: null;
return { enabled, listen, publicBaseUrl };
return { publicBaseUrl, standaloneListen };
}
function resolveVoiceLlmConfig(
@@ -239,6 +239,9 @@ export const PersistedConfigSchema = z
.optional(),
serviceProxy: z
.object({
// COMPAT(serviceProxyEnabled): added 2026-06-02, remove after 2026-12-02.
// Parsed only to suppress optional public/listen layers for old configs;
// localhost service proxying remains always enabled.
enabled: z.boolean().optional(),
listen: z.string().optional(),
publicBaseUrl: z.string().url().optional(),
@@ -162,7 +162,7 @@ describe("ScriptHealthMonitor", () => {
const routeStore = new ScriptRouteStore();
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({
routeStore,
serviceProxy: routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
@@ -214,7 +214,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({
routeStore,
serviceProxy: routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
@@ -261,7 +261,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({
routeStore,
serviceProxy: routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
@@ -292,7 +292,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({
routeStore,
serviceProxy: routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
@@ -343,7 +343,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({
routeStore,
serviceProxy: routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
@@ -393,7 +393,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({
routeStore,
serviceProxy: routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
@@ -450,7 +450,7 @@ describe("ScriptHealthMonitor", () => {
branchName: null,
scriptName,
daemonPort: null,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(
createTerminalCalls,
@@ -462,7 +462,7 @@ describe("ScriptHealthMonitor", () => {
expect(createTerminalCalls).toHaveLength(2);
expect(routeStore.listRoutes()).toEqual([
{
hostname: "api.repo.localhost",
hostname: "api--repo.localhost",
port: service.port,
workspaceId: workspace.repoDir,
projectSlug: "repo",
@@ -472,7 +472,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({
routeStore,
serviceProxy: routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
@@ -487,7 +487,7 @@ describe("ScriptHealthMonitor", () => {
expect(onChange).toHaveBeenCalledWith(workspace.repoDir, [
{
scriptName: "api",
hostname: "api.repo.localhost",
hostname: "api--repo.localhost",
port: service.port,
health: "healthy",
},
@@ -524,7 +524,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({
routeStore,
serviceProxy: routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
@@ -1,5 +1,5 @@
import net from "node:net";
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js";
import type { ServiceProxyHealthTarget, ServiceProxySubsystem } from "./service-proxy.js";
export type ScriptHealthState = "pending" | "healthy" | "unhealthy";
@@ -18,7 +18,7 @@ interface RouteHealthState {
}
export class ScriptHealthMonitor {
private readonly routeStore: ScriptRouteStore;
private readonly serviceProxy: ServiceProxySubsystem;
private readonly onChange: (workspaceId: string, scripts: ScriptHealthEntry[]) => void;
private readonly pollIntervalMs: number;
private readonly probeTimeoutMs: number;
@@ -31,21 +31,21 @@ export class ScriptHealthMonitor {
private pollInFlight = false;
constructor({
routeStore,
serviceProxy,
onChange,
pollIntervalMs = 3_000,
probeTimeoutMs = 500,
graceMs = 5_000,
failuresBeforeStopped = 2,
}: {
routeStore: ScriptRouteStore;
serviceProxy: ServiceProxySubsystem;
onChange: (workspaceId: string, scripts: ScriptHealthEntry[]) => void;
pollIntervalMs?: number;
probeTimeoutMs?: number;
graceMs?: number;
failuresBeforeStopped?: number;
}) {
this.routeStore = routeStore;
this.serviceProxy = serviceProxy;
this.onChange = onChange;
this.pollIntervalMs = pollIntervalMs;
this.probeTimeoutMs = probeTimeoutMs;
@@ -59,7 +59,7 @@ export class ScriptHealthMonitor {
}
const now = Date.now();
for (const route of this.routeStore.listRoutes()) {
for (const route of this.serviceProxy.getHealthCheckTargets()) {
this.getOrCreateState(route, now);
}
@@ -93,7 +93,7 @@ export class ScriptHealthMonitor {
this.pollInFlight = true;
try {
const routes = this.routeStore.listRoutes();
const routes = this.serviceProxy.getHealthCheckTargets();
const activeHostnames = new Set(routes.map((route) => route.hostname));
const changedWorkspaceIds = new Set<string>();
const now = Date.now();
@@ -142,7 +142,7 @@ export class ScriptHealthMonitor {
}
private getOrCreateState(
route: Pick<ScriptRouteEntry, "hostname" | "workspaceId">,
route: Pick<ServiceProxyHealthTarget, "hostname" | "workspaceId">,
registeredAt: number,
): RouteHealthState {
const existing = this.routeStates.get(route.hostname);
@@ -171,7 +171,7 @@ export class ScriptHealthMonitor {
}
private buildWorkspaceScriptList(workspaceId: string): ScriptHealthEntry[] {
return this.routeStore.listRoutesForWorkspace(workspaceId).flatMap((route) => {
return this.serviceProxy.getWorkspaceHealthTargets(workspaceId).flatMap((route) => {
const state = this.routeStates.get(route.hostname);
if (!state) {
return [];
@@ -186,7 +186,7 @@ export class ScriptHealthMonitor {
return state.health;
}
const route = this.routeStore.getRouteEntry(hostname);
const route = this.serviceProxy.getHealthTargetForHostname(hostname);
if (!route) {
return null;
}
@@ -195,7 +195,7 @@ export class ScriptHealthMonitor {
}
private toScriptHealthEntry(
route: ScriptRouteEntry,
route: ServiceProxyHealthTarget,
health: ScriptHealthEntry["health"],
): ScriptHealthEntry {
return {
+8 -476
View File
@@ -1,484 +1,16 @@
import { it, expect, afterEach } from "vitest";
import http from "node:http";
import net from "node:net";
import express from "express";
import { WebSocket, WebSocketServer } from "ws";
import pino from "pino";
import { describe, expect, it } from "vitest";
import {
ScriptRouteStore,
createScriptProxyMiddleware,
createScriptProxyUpgradeHandler,
findFreePort,
ScriptRouteStore,
} from "./script-proxy.js";
const logger = pino({ level: "silent" });
// ---------------------------------------------------------------------------
// Helpers for cleanup
// ---------------------------------------------------------------------------
function closeServer(server: http.Server): Promise<void> {
return new Promise((resolve) => {
server.close(() => resolve());
});
}
// ---------------------------------------------------------------------------
// ScriptRouteStore
// ---------------------------------------------------------------------------
it("registerRoute and findRoute with exact match", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
const route = store.findRoute("route-a.example.localhost");
expect(route).toEqual({ hostname: "route-a.example.localhost", port: 3000 });
});
it("findRoute strips port from host header", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
const route = store.findRoute("route-a.example.localhost:6767");
expect(route).toEqual({ hostname: "route-a.example.localhost", port: 3000 });
});
it("findRoute subdomain match", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "editor.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
const route = store.findRoute("tenant.editor.example.localhost");
expect(route).toEqual({ hostname: "editor.example.localhost", port: 3000 });
});
it("listRoutes returns enriched entries", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
projectSlug: "repo",
scriptName: "docs",
});
const routes = store.listRoutes();
expect(routes).toHaveLength(2);
expect(routes).toContainEqual({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
expect(routes).toContainEqual({
hostname: "b.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
projectSlug: "repo",
scriptName: "docs",
describe("script-proxy compatibility re-exports", () => {
it("keeps the legacy imports available", () => {
expect(createScriptProxyMiddleware).toEqual(expect.any(Function));
expect(createScriptProxyUpgradeHandler).toEqual(expect.any(Function));
expect(findFreePort).toEqual(expect.any(Function));
expect(new ScriptRouteStore()).toEqual(expect.any(Object));
});
});
it("listRoutesForWorkspace returns only routes for that workspace", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
projectSlug: "repo",
scriptName: "docs",
});
store.registerRoute({
hostname: "c.localhost",
port: 5000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "api",
});
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([
{
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
},
{
hostname: "c.localhost",
port: 5000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "api",
},
]);
});
it("removeRoute works", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
store.removeRoute("route-a.example.localhost");
expect(store.findRoute("route-a.example.localhost")).toBeNull();
});
it("removeRoute cleans up workspace index", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
store.removeRoute("route-a.example.localhost");
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]);
});
it("removeRoutesForPort works", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "api",
});
store.registerRoute({
hostname: "c.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
projectSlug: "repo",
scriptName: "docs",
});
store.removeRoutesForPort(3000);
expect(store.findRoute("a.localhost")).toBeNull();
expect(store.findRoute("b.localhost")).toBeNull();
expect(store.findRoute("c.localhost")).toEqual({
hostname: "c.localhost",
port: 4000,
});
});
it("removeRoutesForPort cleans up workspace index", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "api",
});
store.removeRoutesForPort(3000);
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]);
});
it("findRoute returns null for unknown hosts", () => {
const store = new ScriptRouteStore();
store.registerRoute({
hostname: "route-a.example.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
projectSlug: "repo",
scriptName: "editor",
});
expect(store.findRoute("unknown.example.com")).toBeNull();
});
// ---------------------------------------------------------------------------
// HTTP proxy
// ---------------------------------------------------------------------------
const servers: http.Server[] = [];
afterEach(async () => {
await Promise.all(servers.map(closeServer));
servers.length = 0;
});
/** Start a real HTTP server that echoes back a known body and records received headers. */
async function startUpstream(): Promise<{
port: number;
server: http.Server;
receivedHeaders: () => http.IncomingHttpHeaders;
}> {
const port = await findFreePort();
let lastHeaders: http.IncomingHttpHeaders = {};
const server = http.createServer((req, res) => {
lastHeaders = req.headers;
res.writeHead(200, { "content-type": "text/plain" });
res.end("upstream-ok");
});
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
servers.push(server);
return {
port,
server,
receivedHeaders: () => lastHeaders,
};
}
/** Start an Express app with the service proxy middleware and an optional fallback. */
async function startProxy(
routeStore: ScriptRouteStore,
opts?: { fallback?: boolean },
): Promise<{ port: number; server: http.Server }> {
const port = await findFreePort();
const app = express();
app.use(createScriptProxyMiddleware({ routeStore, logger }));
if (opts?.fallback) {
app.use((_req, res) => {
res.status(404).send("no route");
});
}
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
servers.push(server);
return { port, server };
}
/** Simple HTTP GET helper that returns status code and body. */
function httpGet(
port: number,
host: string,
path = "/",
): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const req = http.get({ hostname: "127.0.0.1", port, path, headers: { host } }, (res) => {
let body = "";
res.on("data", (chunk: Buffer) => (body += chunk.toString()));
res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
});
req.on("error", reject);
});
}
it("proxies requests to the correct upstream based on Host header", async () => {
const upstream = await startUpstream();
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "test-service.localhost",
port: upstream.port,
workspaceId: "workspace-test",
projectSlug: "test",
scriptName: "service",
});
const proxy = await startProxy(routeStore);
const res = await httpGet(proxy.port, `test-service.localhost:${proxy.port}`);
expect(res.status).toBe(200);
expect(res.body).toBe("upstream-ok");
const headers = upstream.receivedHeaders();
expect(headers["x-forwarded-for"]).toBeDefined();
expect(headers["x-forwarded-host"]).toBe("test-service.localhost");
});
it("falls through when no route matches", async () => {
const routeStore = new ScriptRouteStore();
const proxy = await startProxy(routeStore, { fallback: true });
const res = await httpGet(proxy.port, `unknown.localhost:${proxy.port}`);
expect(res.status).toBe(404);
expect(res.body).toBe("no route");
});
it("returns 502 when upstream is down", async () => {
// Get a port that nothing is listening on
const deadPort = await findFreePort();
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "dead-service.localhost",
port: deadPort,
workspaceId: "workspace-dead",
projectSlug: "dead",
scriptName: "service",
});
const proxy = await startProxy(routeStore);
const res = await httpGet(proxy.port, `dead-service.localhost:${proxy.port}`);
expect(res.status).toBe(502);
expect(res.body).toBe("502 Bad Gateway");
});
// ---------------------------------------------------------------------------
// WebSocket proxy
// ---------------------------------------------------------------------------
const httpServers: http.Server[] = [];
const wsServers: WebSocketServer[] = [];
const wsClients: WebSocket[] = [];
afterEach(async () => {
for (const ws of wsClients) {
if (ws.readyState === WebSocket.OPEN) ws.close();
}
wsClients.length = 0;
for (const wss of wsServers) {
wss.close();
}
wsServers.length = 0;
await Promise.all(httpServers.map(closeServer));
httpServers.length = 0;
});
it("proxies WebSocket connections to the correct upstream", async () => {
// 1. Start a real WebSocket echo server
const upstreamPort = await findFreePort();
const upstreamServer = http.createServer();
const wss = new WebSocketServer({ server: upstreamServer });
wsServers.push(wss);
wss.on("connection", (ws) => {
ws.on("message", (data) => {
ws.send(`echo: ${data.toString()}`);
});
});
await new Promise<void>((resolve) => upstreamServer.listen(upstreamPort, "127.0.0.1", resolve));
httpServers.push(upstreamServer);
// 2. Create the proxy server with the upgrade handler
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "ws-service.localhost",
port: upstreamPort,
workspaceId: "workspace-ws",
projectSlug: "ws",
scriptName: "service",
});
const proxyPort = await findFreePort();
const proxyServer = http.createServer((_req, res) => {
res.writeHead(404);
res.end();
});
const upgradeHandler = createScriptProxyUpgradeHandler({
routeStore,
logger,
});
proxyServer.on("upgrade", upgradeHandler);
await new Promise<void>((resolve) => proxyServer.listen(proxyPort, "127.0.0.1", resolve));
httpServers.push(proxyServer);
// 3. Connect a WebSocket client through the proxy
const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}`, {
headers: { host: `ws-service.localhost:${proxyPort}` },
});
wsClients.push(ws);
await new Promise<void>((resolve, reject) => {
ws.on("open", resolve);
ws.on("error", reject);
});
// 4. Send a message and verify echo
const reply = await new Promise<string>((resolve, reject) => {
ws.on("message", (data) => resolve(data.toString()));
ws.on("error", reject);
ws.send("hello proxy");
});
expect(reply).toBe("echo: hello proxy");
});
// ---------------------------------------------------------------------------
// findFreePort
// ---------------------------------------------------------------------------
it("returns a number", async () => {
const port = await findFreePort();
expect(typeof port).toBe("number");
expect(port).toBeGreaterThan(0);
expect(port).toBeLessThan(65536);
});
it("returns a port that is actually available", async () => {
const port = await findFreePort();
// Verify we can bind a server to it
const server = net.createServer();
await new Promise<void>((resolve, reject) => {
server.listen(port, "127.0.0.1", () => resolve());
server.on("error", reject);
});
const addr = server.address();
expect(addr).not.toBeNull();
expect(typeof addr === "object" && addr !== null ? addr.port : -1).toBe(port);
await new Promise<void>((resolve) => server.close(() => resolve()));
});
+7 -353
View File
@@ -1,353 +1,7 @@
import http from "node:http";
import net from "node:net";
import type { IncomingMessage } from "node:http";
import type { Logger } from "pino";
import type { RequestHandler } from "express";
// ---------------------------------------------------------------------------
// Hop-by-hop headers that must not be forwarded
// ---------------------------------------------------------------------------
const HOP_BY_HOP_HEADERS = new Set([
"connection",
"transfer-encoding",
"keep-alive",
"upgrade",
"proxy-connection",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
]);
// ---------------------------------------------------------------------------
// ScriptRouteStore
// ---------------------------------------------------------------------------
export interface ScriptRoute {
hostname: string;
port: number;
}
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.removeRoute(previous.hostname);
}
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 canonicalHostname = this.hostnameAliases.get(hostname) ?? hostname;
const entry = this.routes.get(canonicalHostname);
if (!entry) {
return;
}
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 {
const routes = this.listRoutesForWorkspace(params.workspaceId);
const route = routes.find((entry) => entry.scriptName === params.scriptName);
if (!route) {
return;
}
this.removeRoute(route.hostname);
}
removeRoutesForPort(port: number): void {
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);
}
}
}
findRoute(host: string): ScriptRoute | null {
// Strip port suffix from the Host header value
const hostname = host.replace(/:\d+$/, "");
// 1. Exact match
const exactRoute = this.getRouteByHostname(hostname);
if (exactRoute !== undefined) {
return { hostname: exactRoute.hostname, port: exactRoute.port };
}
// 2. Subdomain match — walk up the labels looking for a registered parent
const parts = hostname.split(".");
for (let i = 1; i < parts.length; i++) {
const candidate = parts.slice(i).join(".");
const candidateRoute = this.getRouteByHostname(candidate);
if (candidateRoute !== undefined) {
return { hostname: candidateRoute.hostname, port: candidateRoute.port };
}
}
return null;
}
getRouteEntry(hostname: string): ScriptRouteEntry | null {
const entry = this.getRouteByHostname(hostname);
return entry ? { ...entry } : null;
}
listRoutes(): ScriptRouteEntry[] {
return Array.from(this.routes.values()).map((entry) => Object.assign({}, entry));
}
listRoutesForWorkspace(workspaceId: string): ScriptRouteEntry[] {
const hostnames = this.workspaceHostnames.get(workspaceId);
if (!hostnames) {
return [];
}
const routes: ScriptRouteEntry[] = [];
for (const hostname of hostnames) {
const entry = this.routes.get(hostname);
if (entry) {
routes.push({ ...entry });
}
}
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);
this.workspaceHostnames.set(workspaceId, hostnames);
}
private removeHostnameFromWorkspaceIndex(workspaceId: string, hostname: string): void {
const hostnames = this.workspaceHostnames.get(workspaceId);
if (!hostnames) {
return;
}
hostnames.delete(hostname);
if (hostnames.size === 0) {
this.workspaceHostnames.delete(workspaceId);
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function stripHopByHopHeaders(
rawHeaders: http.IncomingHttpHeaders,
): Record<string, string | string[]> {
const out: Record<string, string | string[]> = {};
for (const [key, value] of Object.entries(rawHeaders)) {
if (value === undefined) continue;
if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
out[key] = value;
}
return out;
}
// ---------------------------------------------------------------------------
// createScriptProxyMiddleware
// ---------------------------------------------------------------------------
export function createScriptProxyMiddleware({
routeStore,
logger,
}: {
routeStore: ScriptRouteStore;
logger: Logger;
}): RequestHandler {
return (req, res, next) => {
const hostHeader = req.headers.host;
if (!hostHeader) {
next();
return;
}
const route = routeStore.findRoute(hostHeader);
if (!route) {
next();
return;
}
const forwardedHeaders = stripHopByHopHeaders(req.headers);
forwardedHeaders["x-forwarded-for"] = req.socket.remoteAddress ?? "127.0.0.1";
forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, "");
forwardedHeaders["x-forwarded-proto"] = req.protocol;
const proxyReq = http.request(
{
hostname: "127.0.0.1",
port: route.port,
path: req.originalUrl,
method: req.method,
headers: forwardedHeaders,
},
(proxyRes) => {
const responseHeaders = stripHopByHopHeaders(proxyRes.headers);
res.writeHead(proxyRes.statusCode ?? 502, responseHeaders);
proxyRes.pipe(res, { end: true });
},
);
proxyReq.on("error", (err) => {
logger.warn(
{ err, hostname: route.hostname, port: route.port },
"Script proxy: upstream unreachable",
);
if (!res.headersSent) {
res.writeHead(502, { "content-type": "text/plain" });
res.end("502 Bad Gateway");
}
});
req.pipe(proxyReq, { end: true });
};
}
// ---------------------------------------------------------------------------
// createScriptProxyUpgradeHandler
// ---------------------------------------------------------------------------
export function createScriptProxyUpgradeHandler({
routeStore,
logger,
}: {
routeStore: ScriptRouteStore;
logger: Logger;
}): (req: IncomingMessage, socket: net.Socket, head: Buffer) => void {
return (req, socket, head) => {
const hostHeader = req.headers.host;
if (!hostHeader) {
return;
}
const route = routeStore.findRoute(hostHeader);
if (!route) {
return;
}
const targetSocket = net.connect({ host: "127.0.0.1", port: route.port }, () => {
// Reconstruct the raw HTTP upgrade request to send to the target
const forwardedHeaders = stripHopByHopHeaders(req.headers);
forwardedHeaders["x-forwarded-for"] = req.socket.remoteAddress ?? "127.0.0.1";
forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, "");
forwardedHeaders["x-forwarded-proto"] = "http";
// Re-include upgrade and connection headers — they are required for
// WebSocket handshake even though they are hop-by-hop.
forwardedHeaders["connection"] = "Upgrade";
forwardedHeaders["upgrade"] = req.headers.upgrade ?? "websocket";
const headerLines: string[] = [];
headerLines.push(`${req.method ?? "GET"} ${req.url ?? "/"} HTTP/${req.httpVersion}`);
for (const [key, value] of Object.entries(forwardedHeaders)) {
if (Array.isArray(value)) {
for (const v of value) {
headerLines.push(`${key}: ${v}`);
}
} else {
headerLines.push(`${key}: ${value}`);
}
}
headerLines.push("\r\n");
targetSocket.write(headerLines.join("\r\n"));
if (head.length > 0) {
targetSocket.write(head);
}
// Pipe in both directions
targetSocket.pipe(socket);
socket.pipe(targetSocket);
});
targetSocket.on("error", (err) => {
logger.warn(
{ err, hostname: route.hostname, port: route.port },
"Script proxy: WebSocket upstream unreachable",
);
socket.end();
});
socket.on("error", () => {
targetSocket.destroy();
});
};
}
// ---------------------------------------------------------------------------
// findFreePort
// ---------------------------------------------------------------------------
export function findFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("Failed to get assigned port"));
return;
}
const { port } = address;
server.close((err) => {
if (err) {
reject(err);
} else {
resolve(port);
}
});
});
server.on("error", reject);
});
}
export {
createScriptProxyMiddleware,
createScriptProxyUpgradeHandler,
findFreePort,
ScriptRouteStore,
} from "./service-proxy.js";
export type { ScriptRoute, ScriptRouteEntry } from "./service-proxy.js";
@@ -76,22 +76,22 @@ describe("script-route-branch-handler", () => {
it("updates routes on branch rename by removing old hostnames and registering new ones", () => {
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {
hostname: "api.feature-auth.paseo.localhost",
hostname: "api--feature-auth--paseo.localhost",
port: 3001,
scriptName: "api",
});
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
serviceProxy: routeStore,
onRoutesChanged,
});
handleBranchChange("workspace-a", "feature/auth", "feature/billing");
expect(routeStore.findRoute("api.feature-auth.paseo.localhost")).toBeNull();
expect(routeStore.findRoute("api.feature-billing.paseo.localhost")).toEqual({
hostname: "api.feature-billing.paseo.localhost",
expect(routeStore.findRoute("api--feature-auth--paseo.localhost")).toBeNull();
expect(routeStore.findRoute("api--feature-billing--paseo.localhost")).toEqual({
hostname: "api--feature-billing--paseo.localhost",
port: 3001,
});
});
@@ -100,7 +100,7 @@ describe("script-route-branch-handler", () => {
const routeStore = new ScriptRouteStore();
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
serviceProxy: routeStore,
onRoutesChanged,
});
@@ -113,14 +113,14 @@ describe("script-route-branch-handler", () => {
it("is a no-op when the resolved hostnames do not change", () => {
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {
hostname: "api.paseo.localhost",
hostname: "api--paseo.localhost",
port: 3001,
scriptName: "api",
});
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
serviceProxy: routeStore,
onRoutesChanged,
});
@@ -128,7 +128,7 @@ describe("script-route-branch-handler", () => {
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
{
hostname: "api.paseo.localhost",
hostname: "api--paseo.localhost",
port: 3001,
workspaceId: "workspace-a",
projectSlug: "paseo",
@@ -141,14 +141,14 @@ describe("script-route-branch-handler", () => {
it("triggers shared reprojection after a route change", () => {
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {
hostname: "api.feature-auth.paseo.localhost",
hostname: "api--feature-auth--paseo.localhost",
port: 3001,
scriptName: "api",
});
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
serviceProxy: routeStore,
onRoutesChanged,
});
@@ -160,7 +160,7 @@ describe("script-route-branch-handler", () => {
it("updates public route aliases from the stored public base URL", () => {
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {
hostname: "api.feature-auth.paseo.localhost",
hostname: "api--feature-auth--paseo.localhost",
publicHostname: "api--feature-auth--paseo.services.example.com",
publicBaseUrl: "https://services.example.com:8443",
port: 3001,
@@ -169,7 +169,7 @@ describe("script-route-branch-handler", () => {
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
serviceProxy: routeStore,
onRoutesChanged,
});
@@ -177,12 +177,12 @@ describe("script-route-branch-handler", () => {
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",
hostname: "api--feature-billing--paseo.localhost",
port: 3001,
});
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
{
hostname: "api.feature-billing.paseo.localhost",
hostname: "api--feature-billing--paseo.localhost",
publicHostname: "api--feature-billing--paseo.services.example.com",
publicBaseUrl: "https://services.example.com:8443",
port: 3001,
@@ -196,17 +196,17 @@ describe("script-route-branch-handler", () => {
it("updates all services for a workspace when multiple routes are registered", () => {
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {
hostname: "api.feature-auth.paseo.localhost",
hostname: "api--feature-auth--paseo.localhost",
port: 3001,
scriptName: "api",
});
registerRoute(routeStore, {
hostname: "web.feature-auth.paseo.localhost",
hostname: "web--feature-auth--paseo.localhost",
port: 3002,
scriptName: "web",
});
registerRoute(routeStore, {
hostname: "docs.docs-app.localhost",
hostname: "docs--docs-app.localhost",
port: 3003,
workspaceId: "workspace-b",
projectSlug: "docs-app",
@@ -215,7 +215,7 @@ describe("script-route-branch-handler", () => {
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
serviceProxy: routeStore,
onRoutesChanged,
});
@@ -223,14 +223,14 @@ describe("script-route-branch-handler", () => {
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
{
hostname: "api.feature-billing.paseo.localhost",
hostname: "api--feature-billing--paseo.localhost",
port: 3001,
workspaceId: "workspace-a",
projectSlug: "paseo",
scriptName: "api",
},
{
hostname: "web.feature-billing.paseo.localhost",
hostname: "web--feature-billing--paseo.localhost",
port: 3002,
workspaceId: "workspace-a",
projectSlug: "paseo",
@@ -239,7 +239,7 @@ describe("script-route-branch-handler", () => {
]);
expect(routeStore.listRoutesForWorkspace("workspace-b")).toEqual([
{
hostname: "docs.docs-app.localhost",
hostname: "docs--docs-app.localhost",
port: 3003,
workspaceId: "workspace-b",
projectSlug: "docs-app",
@@ -251,14 +251,14 @@ describe("script-route-branch-handler", () => {
it("does not emit a status update when no changes are needed", () => {
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {
hostname: "web.paseo.localhost",
hostname: "web--paseo.localhost",
port: 3002,
scriptName: "web",
});
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
serviceProxy: routeStore,
onRoutesChanged,
});
@@ -279,7 +279,7 @@ describe("script-route-branch-handler", () => {
});
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {
hostname: "api.feature-auth.repo.localhost",
hostname: "api--feature-auth--repo.localhost",
port: 3001,
workspaceId: workspace.repoDir,
projectSlug: "repo",
@@ -288,7 +288,7 @@ describe("script-route-branch-handler", () => {
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
serviceProxy: routeStore,
onRoutesChanged,
});
@@ -297,7 +297,7 @@ describe("script-route-branch-handler", () => {
expect(routeStore.listRoutesForWorkspace(workspace.repoDir)).toEqual([
{
hostname: "api.feature-billing.repo.localhost",
hostname: "api--feature-billing--repo.localhost",
port: 3001,
workspaceId: workspace.repoDir,
projectSlug: "repo",
@@ -309,4 +309,99 @@ describe("script-route-branch-handler", () => {
workspace.cleanup();
}
});
it("leaves existing local and public routes intact when a branch rename collides", () => {
const routeStore = new ScriptRouteStore();
registerRoute(routeStore, {
hostname: "api--feature-auth--repo.localhost",
publicHostname: "api--feature-auth--repo.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 3001,
workspaceId: "workspace-a",
projectSlug: "repo",
scriptName: "api",
});
registerRoute(routeStore, {
hostname: "api--feature-billing--repo.localhost",
publicHostname: "api--feature-billing--repo.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 4001,
workspaceId: "workspace-b",
projectSlug: "repo",
scriptName: "api",
});
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
serviceProxy: routeStore,
onRoutesChanged,
});
expect(() => handleBranchChange("workspace-a", "feature/auth", "feature/billing")).toThrow(
"Service proxy hostname collision",
);
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
{
hostname: "api--feature-auth--repo.localhost",
publicHostname: "api--feature-auth--repo.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 3001,
workspaceId: "workspace-a",
projectSlug: "repo",
scriptName: "api",
},
]);
expect(routeStore.getRouteEntry("api--feature-auth--repo.services.example.com")).toMatchObject({
workspaceId: "workspace-a",
port: 3001,
});
expect(
routeStore.getRouteEntry("api--feature-billing--repo.services.example.com"),
).toMatchObject({
workspaceId: "workspace-b",
port: 4001,
});
expect(onRoutesChanged).not.toHaveBeenCalled();
});
it("leaves old routes intact when branch rename creates an internal incoming collision", () => {
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "api--feature-one--repo.localhost",
publicHostname: "api--feature-one--repo.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 3001,
workspaceId: "workspace-a",
projectSlug: "repo",
scriptName: "api",
});
routeStore.registerRoute({
hostname: "api--feature-two--repo.localhost",
publicHostname: "api--feature-two--repo.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 3002,
workspaceId: "workspace-a",
projectSlug: "repo",
scriptName: "api",
});
const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
serviceProxy: routeStore,
onRoutesChanged,
});
expect(() => handleBranchChange("workspace-a", "feature/one", "feature/collide")).toThrow(
"Service proxy hostname collision",
);
expect(routeStore.getRouteEntry("api--feature-one--repo.localhost")).toMatchObject({
port: 3001,
});
expect(routeStore.getRouteEntry("api--feature-two--repo.localhost")).toMatchObject({
port: 3002,
});
expect(routeStore.getRouteEntry("api--feature-collide--repo.localhost")).toBeNull();
expect(onRoutesChanged).not.toHaveBeenCalled();
});
});
@@ -1,80 +1,24 @@
import type { Logger } from "pino";
import { buildPublicScriptHostname, buildScriptHostname } from "../utils/script-hostname.js";
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js";
import type { ServiceProxySubsystem } from "./service-proxy.js";
interface BranchChangeRouteHandlerOptions {
routeStore: ScriptRouteStore;
serviceProxy: ServiceProxySubsystem;
onRoutesChanged: (workspaceId: string) => void;
logger?: Logger;
}
interface RouteHostnameUpdate {
oldHostname: string;
newHostname: string;
newPublicHostname: string | null;
route: ScriptRouteEntry;
}
export function createBranchChangeRouteHandler(
options: BranchChangeRouteHandlerOptions,
): (workspaceId: string, oldBranch: string | null, newBranch: string | null) => void {
return (workspaceId, _oldBranch, newBranch) => {
// Only service scripts register routes, so branch renames only touch services.
const routes = options.routeStore.listRoutesForWorkspace(workspaceId);
if (routes.length === 0) {
const changed = options.serviceProxy.replaceWorkspaceBranchRoutes({ workspaceId, newBranch });
if (!changed) {
return;
}
const updates: RouteHostnameUpdate[] = [];
for (const route of routes) {
const newHostname = buildScriptHostname({
projectSlug: route.projectSlug,
branchName: newBranch,
scriptName: route.scriptName,
});
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,
});
}
}
if (updates.length === 0) {
return;
}
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,
scriptName: route.scriptName,
});
options.logger?.info(
{
oldHostname,
newHostname,
scriptName: route.scriptName,
},
"Updated script route for branch rename",
{ workspaceId, newBranch },
"Updated service proxy routes for branch rename",
);
}
options.onRoutesChanged(workspaceId);
};
}
@@ -54,15 +54,22 @@ function buildPayloads(input: {
workspaceId: string;
workspaceDirectory: string;
paseoConfig?: PaseoConfig | null;
routeStore: ScriptRouteStore;
routeStore?: ScriptRouteStore;
serviceProxy?: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null;
serviceProxyPublicBaseUrl?: string | null;
gitMetadata?: { projectSlug: string; currentBranch: string | null };
resolveHealth?: (hostname: string) => ScriptHealthState | null;
}) {
const paseoConfig =
input.paseoConfig !== undefined ? input.paseoConfig : loadConfig(input.workspaceDirectory);
return buildWorkspaceScriptPayloads({ ...input, paseoConfig });
const { routeStore, serviceProxy, ...rest } = input;
return buildWorkspaceScriptPayloads({
...rest,
serviceProxy: serviceProxy ?? routeStore ?? new ScriptRouteStore(),
paseoConfig,
});
}
function loadConfig(repoRoot: string): PaseoConfig | null {
@@ -131,9 +138,11 @@ describe("script-status-projection", () => {
{
scriptName: "web",
type: "service",
hostname: "web.repo.localhost",
hostname: "web--repo.localhost",
port: 3000,
proxyUrl: "http://web.repo.localhost:6767",
localProxyUrl: "http://web--repo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://web--repo.localhost:6767",
lifecycle: "stopped",
health: null,
exitCode: null,
@@ -162,7 +171,7 @@ describe("script-status-projection", () => {
const payloads = buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
routeStore,
serviceProxy: routeStore,
runtimeStore,
daemonPort: 6767,
gitMetadata: {
@@ -175,9 +184,54 @@ describe("script-status-projection", () => {
{
scriptName: "web",
type: "service",
hostname: "web.feature-from-service.service-provided.localhost",
hostname: "web--feature-from-service--service-provided.localhost",
port: 3000,
proxyUrl: "http://web.feature-from-service.service-provided.localhost:6767",
localProxyUrl: "http://web--feature-from-service--service-provided.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://web--feature-from-service--service-provided.localhost:6767",
lifecycle: "stopped",
health: null,
exitCode: null,
terminalId: null,
},
]);
} finally {
workspace.cleanup();
}
});
it("projects local and public service URLs while keeping proxyUrl public-first", () => {
const workspaceId = "workspace-public-service";
const workspace = createWorkspaceRepo({
paseoConfig: {
scripts: {
web: { type: "service", command: "npm run web", port: 3000 },
},
},
});
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
try {
expect(
buildPayloads({
workspaceId,
workspaceDirectory: workspace.repoDir,
routeStore,
runtimeStore,
daemonPort: 6767,
serviceProxyPublicBaseUrl: "https://services.example.com",
gitMetadata: { projectSlug: "repo", currentBranch: "feature/card" },
}),
).toEqual([
{
scriptName: "web",
type: "service",
hostname: "web--feature-card--repo.localhost",
port: 3000,
localProxyUrl: "http://web--feature-card--repo.localhost:6767",
publicProxyUrl: "https://web--feature-card--repo.services.example.com",
proxyUrl: "https://web--feature-card--repo.services.example.com",
lifecycle: "stopped",
health: null,
exitCode: null,
@@ -201,7 +255,7 @@ describe("script-status-projection", () => {
});
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "web.feature-card.repo.localhost",
hostname: "web--feature-card--repo.localhost",
port: 4321,
workspaceId,
projectSlug: "repo",
@@ -231,9 +285,11 @@ describe("script-status-projection", () => {
{
scriptName: "web",
type: "service",
hostname: "web.feature-card.repo.localhost",
hostname: "web--feature-card--repo.localhost",
port: 4321,
proxyUrl: "http://web.feature-card.repo.localhost:6767",
localProxyUrl: "http://web--feature-card--repo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://web--feature-card--repo.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
@@ -256,7 +312,7 @@ describe("script-status-projection", () => {
});
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "web.repo.localhost",
hostname: "web--repo.localhost",
port: 4321,
workspaceId,
projectSlug: "repo",
@@ -286,9 +342,11 @@ describe("script-status-projection", () => {
{
scriptName: "web",
type: "service",
hostname: "web.repo.localhost",
hostname: "web--repo.localhost",
port: 4321,
proxyUrl: "http://web.repo.localhost:6767",
localProxyUrl: "http://web--repo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://web--repo.localhost:6767",
lifecycle: "running",
health: null,
exitCode: null,
@@ -305,7 +363,7 @@ describe("script-status-projection", () => {
const workspace = createWorkspaceRepo();
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "docs.repo.localhost",
hostname: "docs--repo.localhost",
port: 3002,
workspaceId,
projectSlug: "repo",
@@ -334,9 +392,11 @@ describe("script-status-projection", () => {
{
scriptName: "docs",
type: "service",
hostname: "docs.repo.localhost",
hostname: "docs--repo.localhost",
port: 3002,
proxyUrl: "http://docs.repo.localhost:6767",
localProxyUrl: "http://docs--repo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://docs--repo.localhost:6767",
lifecycle: "running",
health: null,
exitCode: null,
@@ -462,7 +522,7 @@ describe("script-status-projection", () => {
});
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "api.repo.localhost",
hostname: "api--repo.localhost",
port: 3001,
workspaceId,
projectSlug: "repo",
@@ -481,7 +541,7 @@ describe("script-status-projection", () => {
const session = { emit: vi.fn() };
const emitUpdate = createScriptStatusEmitter({
sessions: () => [session],
routeStore,
serviceProxy: routeStore,
runtimeStore,
daemonPort: 6767,
resolveWorkspaceDirectory: async (requestedWorkspaceId) =>
@@ -493,7 +553,7 @@ describe("script-status-projection", () => {
emitUpdate(workspaceId, [
{
scriptName: "api",
hostname: "api.repo.localhost",
hostname: "api--repo.localhost",
port: 3001,
health: "healthy",
},
@@ -508,9 +568,11 @@ describe("script-status-projection", () => {
{
scriptName: "api",
type: "service",
hostname: "api.repo.localhost",
hostname: "api--repo.localhost",
port: 3001,
proxyUrl: "http://api.repo.localhost:6767",
localProxyUrl: "http://api--repo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://api--repo.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
@@ -5,11 +5,13 @@ import type {
WorkspaceScriptPayload,
} from "@getpaseo/protocol/messages";
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
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";
import type { ScriptRouteStore } from "./script-proxy.js";
import type {
ServiceProxySubsystem,
ServiceProxyWorkspaceScriptProjection,
} from "./service-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
interface SessionEmitter {
@@ -20,7 +22,7 @@ interface BuildWorkspaceScriptPayloadsOptions {
workspaceId: string;
workspaceDirectory: string;
paseoConfig: PaseoConfig | null;
routeStore: ScriptRouteStore;
serviceProxy: ServiceProxySubsystem;
runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null;
serviceProxyPublicBaseUrl?: string | null;
@@ -53,28 +55,6 @@ function resolveDaemonPort(daemonPort: number | null | (() => number | null)): n
return daemonPort;
}
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://${params.hostname}:${params.daemonPort}`;
}
function toWireHealth(health: ScriptHealthState | null): WorkspaceScriptPayload["health"] {
if (health === "pending" || health === null) {
return null;
@@ -92,54 +72,92 @@ function sortPayloads(payloads: WorkspaceScriptPayload[]): WorkspaceScriptPayloa
}
type RuntimeEntry = ReturnType<WorkspaceScriptRuntimeStore["listForWorkspace"]>[number];
type RouteEntry = ReturnType<ScriptRouteStore["listRoutesForWorkspace"]>[number];
interface BuildPayloadContext {
projectSlug: string;
branchName: string | null;
daemonPort: number | null;
serviceProxyPublicBaseUrl?: string | null;
serviceProxy: ServiceProxySubsystem;
resolveHealth?: (hostname: string) => ScriptHealthState | null;
}
function projectWorkspaceServiceState(params: {
workspaceId: string;
scriptName: string;
ctx: BuildPayloadContext;
}): ServiceProxyWorkspaceScriptProjection {
return params.ctx.serviceProxy.projectWorkspaceServiceState({
workspaceId: params.workspaceId,
projectSlug: params.ctx.projectSlug,
branchName: params.ctx.branchName,
scriptName: params.scriptName,
daemonPort: params.ctx.daemonPort,
publicBaseUrl: params.ctx.serviceProxyPublicBaseUrl,
});
}
function buildConfiguredPlainScriptPayload(
scriptName: string,
runtimeEntry: RuntimeEntry | null,
): WorkspaceScriptPayload {
return {
scriptName,
type: "script",
hostname: scriptName,
port: null,
proxyUrl: null,
lifecycle: runtimeEntry?.lifecycle ?? "stopped",
health: null,
exitCode: runtimeEntry?.exitCode ?? null,
terminalId: runtimeEntry?.terminalId ?? null,
};
}
function buildConfiguredScriptPayload(
scriptName: string,
config: ReturnType<typeof getScriptConfigs> extends Map<string, infer V> ? V : never,
runtimeEntry: RuntimeEntry | null,
routeEntry: RouteEntry | null,
serviceState: ServiceProxyWorkspaceScriptProjection | null,
ctx: BuildPayloadContext,
): WorkspaceScriptPayload {
const configIsService = isServiceScript(config);
const type = configIsService ? "service" : "script";
const configuredPort = configIsService ? (config.port ?? null) : null;
const hostname =
type === "service"
? (routeEntry?.hostname ??
buildScriptHostname({
if (!configIsService) {
return buildConfiguredPlainScriptPayload(scriptName, runtimeEntry);
}
const type = "service";
const configuredPort = config.port ?? null;
const hostname = (
serviceState ??
ctx.serviceProxy.projectWorkspaceService({
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName,
}))
: scriptName;
daemonPort: ctx.daemonPort,
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
})
).hostname;
const urls =
serviceState ??
ctx.serviceProxy.projectUrls({
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName,
daemonPort: ctx.daemonPort,
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
});
return {
scriptName,
type,
hostname,
port: type === "service" ? (routeEntry?.port ?? configuredPort) : null,
proxyUrl:
type === "service"
? toServiceProxyUrl({
hostname,
daemonPort: ctx.daemonPort,
serviceProxyPublicBaseUrl: ctx.serviceProxyPublicBaseUrl,
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName,
})
: null,
port: serviceState?.port ?? configuredPort,
localProxyUrl: urls.localProxyUrl,
publicProxyUrl: urls.publicProxyUrl,
proxyUrl: urls.proxyUrl,
lifecycle: runtimeEntry?.lifecycle ?? "stopped",
health: type === "service" ? toWireHealth(ctx.resolveHealth?.(hostname) ?? null) : null,
health: toWireHealth(ctx.resolveHealth?.(hostname) ?? null),
exitCode: runtimeEntry?.exitCode ?? null,
terminalId: runtimeEntry?.terminalId ?? null,
};
@@ -147,38 +165,47 @@ function buildConfiguredScriptPayload(
function buildOrphanRuntimePayload(
runtimeEntry: RuntimeEntry,
routeEntry: RouteEntry | null,
serviceState: ServiceProxyWorkspaceScriptProjection | null,
ctx: BuildPayloadContext,
): WorkspaceScriptPayload {
const type = runtimeEntry.type;
const hostname =
type === "service"
? (routeEntry?.hostname ??
buildScriptHostname({
? (
serviceState ??
ctx.serviceProxy.projectWorkspaceService({
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName: runtimeEntry.scriptName,
}))
daemonPort: ctx.daemonPort,
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
})
).hostname
: runtimeEntry.scriptName;
const urls =
serviceState ??
ctx.serviceProxy.projectUrls({
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName: runtimeEntry.scriptName,
daemonPort: ctx.daemonPort,
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
});
return {
scriptName: runtimeEntry.scriptName,
type,
hostname,
port: type === "service" ? (routeEntry?.port ?? null) : null,
proxyUrl:
type === "service"
? toServiceProxyUrl({
hostname,
daemonPort: ctx.daemonPort,
serviceProxyPublicBaseUrl: ctx.serviceProxyPublicBaseUrl,
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName: runtimeEntry.scriptName,
})
: null,
port: type === "service" ? (serviceState?.port ?? null) : null,
...(type === "service"
? { localProxyUrl: urls.localProxyUrl, publicProxyUrl: urls.publicProxyUrl }
: {}),
proxyUrl: type === "service" ? urls.proxyUrl : null,
lifecycle: runtimeEntry.lifecycle,
health:
type === "service" && routeEntry ? toWireHealth(ctx.resolveHealth?.(hostname) ?? null) : null,
type === "service" && serviceState?.port !== null
? toWireHealth(ctx.resolveHealth?.(hostname) ?? null)
: null,
exitCode: runtimeEntry.exitCode,
terminalId: runtimeEntry.terminalId,
};
@@ -197,17 +224,12 @@ export function buildWorkspaceScriptPayloads(
.listForWorkspace(workspaceId)
.map((entry) => [entry.scriptName, entry] as const),
);
const routesByScriptName = new Map(
options.routeStore
.listRoutesForWorkspace(workspaceId)
.map((entry) => [entry.scriptName, entry] as const),
);
const ctx: BuildPayloadContext = {
projectSlug,
branchName,
daemonPort: options.daemonPort,
serviceProxyPublicBaseUrl: options.serviceProxyPublicBaseUrl,
serviceProxy: options.serviceProxy,
resolveHealth: options.resolveHealth,
};
@@ -215,16 +237,23 @@ export function buildWorkspaceScriptPayloads(
for (const [scriptName, config] of scriptConfigs.entries()) {
const runtimeEntry = runtimeEntries.get(scriptName) ?? null;
const routeEntry = routesByScriptName.get(scriptName) ?? null;
payloads.push(buildConfiguredScriptPayload(scriptName, config, runtimeEntry, routeEntry, ctx));
const serviceState = isServiceScript(config)
? projectWorkspaceServiceState({ workspaceId, scriptName, ctx })
: null;
payloads.push(
buildConfiguredScriptPayload(scriptName, config, runtimeEntry, serviceState, ctx),
);
}
for (const runtimeEntry of runtimeEntries.values()) {
if (scriptConfigs.has(runtimeEntry.scriptName) || runtimeEntry.lifecycle !== "running") {
continue;
}
const routeEntry = routesByScriptName.get(runtimeEntry.scriptName) ?? null;
payloads.push(buildOrphanRuntimePayload(runtimeEntry, routeEntry, ctx));
const serviceState =
runtimeEntry.type === "service"
? projectWorkspaceServiceState({ workspaceId, scriptName: runtimeEntry.scriptName, ctx })
: null;
payloads.push(buildOrphanRuntimePayload(runtimeEntry, serviceState, ctx));
}
return sortPayloads(payloads);
@@ -245,7 +274,7 @@ function buildScriptStatusUpdateMessage(params: {
export function createScriptStatusEmitter({
sessions,
routeStore,
serviceProxy,
runtimeStore,
daemonPort,
serviceProxyPublicBaseUrl,
@@ -253,7 +282,7 @@ export function createScriptStatusEmitter({
logger,
}: {
sessions: () => SessionEmitter[];
routeStore: ScriptRouteStore;
serviceProxy: ServiceProxySubsystem;
runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null | (() => number | null);
serviceProxyPublicBaseUrl?: string | null;
@@ -276,7 +305,7 @@ export function createScriptStatusEmitter({
workspaceId,
workspaceDirectory,
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger),
routeStore,
serviceProxy,
runtimeStore,
daemonPort: resolvedDaemonPort,
serviceProxyPublicBaseUrl,
@@ -0,0 +1,295 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import http from "node:http";
import express from "express";
import { describe, expect, it } from "vitest";
import pino from "pino";
import {
buildLocalServiceHostname,
buildPublicServiceHostname,
buildServiceProxyLabel,
createServiceProxySubsystem,
findFreePort,
ServiceProxyRouteRegistry,
} from "./service-proxy.js";
const logger = pino({ level: "silent" });
function readServerSourceFiles(dir = path.resolve(import.meta.dirname)): string[] {
const entries: string[] = [];
for (const name of readdirSync(dir)) {
const fullPath = path.join(dir, name);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
entries.push(...readServerSourceFiles(fullPath));
} else if (fullPath.endsWith(".ts") && !fullPath.endsWith(".test.ts")) {
entries.push(fullPath);
}
}
return entries;
}
function httpGet(port: number, host: string, requestPath = "/api/health") {
return new Promise<{ status: number; body: string }>((resolve, reject) => {
const req = http.get(
{ hostname: "127.0.0.1", port, path: requestPath, headers: { host } },
(res) => {
let body = "";
res.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
},
);
req.on("error", reject);
});
}
describe("service proxy subsystem shape", () => {
it("keeps production imports behind the service-proxy entrypoint", () => {
const offenders: string[] = [];
for (const filePath of readServerSourceFiles()) {
if (filePath.endsWith("service-proxy.ts") || filePath.endsWith("script-proxy.ts")) {
continue;
}
const source = readFileSync(filePath, "utf8");
for (const needle of ["./script-proxy.js", "../utils/script-hostname.js"]) {
if (source.includes(needle)) {
offenders.push(`${path.relative(import.meta.dirname, filePath)} imports ${needle}`);
}
}
}
expect(offenders).toEqual([]);
});
it("classifies the configured public namespace before any route exists", async () => {
const serviceProxy = createServiceProxySubsystem({
logger,
publicBaseUrl: "https://services.example.com",
});
const port = await findFreePort();
const app = express();
app.use(serviceProxy.middleware());
app.use((_req, res) => {
res.status(200).send("daemon-api");
});
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
try {
await expect(httpGet(port, `missing.services.example.com:${port}`)).resolves.toEqual({
status: 404,
body: "404 Not Found",
});
await expect(httpGet(port, `daemon.localhost:${port}`)).resolves.toEqual({
status: 200,
body: "daemon-api",
});
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("keeps configured public namespace classified after the last public route is removed", async () => {
const serviceProxy = createServiceProxySubsystem({
logger,
publicBaseUrl: "https://services.example.com",
});
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 3000,
publicBaseUrl: "https://services.example.com",
});
serviceProxy.removeWorkspaceService({ workspaceId: "workspace-a", scriptName: "api" });
const port = await findFreePort();
const app = express();
app.use(serviceProxy.middleware());
app.use((_req, res) => {
res.status(200).send("daemon-api");
});
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
try {
await expect(httpGet(port, `missing.services.example.com:${port}`)).resolves.toEqual({
status: 404,
body: "404 Not Found",
});
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("uses the same hash-truncated service label for local and public hostnames", () => {
const input = {
projectSlug: "project-".repeat(10),
branchName: "branch-".repeat(10),
scriptName: "script-".repeat(10),
};
const label = buildServiceProxyLabel(input);
expect(label.length).toBeLessThanOrEqual(63);
expect(label.endsWith("-")).toBe(false);
expect(buildLocalServiceHostname(input)).toBe(`${label}.localhost`);
expect(
buildPublicServiceHostname({ ...input, publicBaseUrl: "https://services.example.com" }),
).toBe(`${label}.services.example.com`);
expect(buildServiceProxyLabel(input)).toBe(label);
expect(
buildServiceProxyLabel({ ...input, scriptName: `different-${input.scriptName}` }),
).not.toBe(label);
});
it("gives long labels with the same prefix different hash suffixes", () => {
const sharedPrefix = "service-".repeat(12);
const first = buildServiceProxyLabel({
projectSlug: "repo",
branchName: "feature/shared-prefix",
scriptName: `${sharedPrefix}alpha`,
});
const second = buildServiceProxyLabel({
projectSlug: "repo",
branchName: "feature/shared-prefix",
scriptName: `${sharedPrefix}beta`,
});
expect(first).not.toBe(second);
expect(first.slice(0, -10)).toBe(second.slice(0, -10));
expect(first.split("--").at(-1)).not.toBe(second.split("--").at(-1));
});
it("rejects cross-service collisions without deleting the existing route", () => {
const serviceProxy = createServiceProxySubsystem({ logger });
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 3000,
});
expect(() =>
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-b",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 4000,
}),
).toThrow("Service proxy hostname collision");
expect(serviceProxy.getHealthTargetForHostname("api--repo.localhost")).toMatchObject({
workspaceId: "workspace-a",
port: 3000,
});
});
it("rejects public alias collisions without deleting the existing route", () => {
const serviceProxy = createServiceProxySubsystem({ logger });
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 3000,
publicBaseUrl: "https://services.example.com",
});
expect(() =>
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-b",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 4000,
publicBaseUrl: "https://services.example.com",
}),
).toThrow("Service proxy hostname collision");
expect(serviceProxy.getHealthTargetForHostname("api--repo.services.example.com")).toMatchObject(
{
workspaceId: "workspace-a",
port: 3000,
},
);
});
it("rejects public alias collisions even when canonical hostnames differ", () => {
const serviceProxy = new ServiceProxyRouteRegistry();
serviceProxy.registerRoute({
hostname: "api--repo-a.localhost",
publicHostname: "api.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 3000,
workspaceId: "workspace-a",
projectSlug: "repo-a",
scriptName: "api",
});
expect(() =>
serviceProxy.registerRoute({
hostname: "api--repo-b.localhost",
publicHostname: "api.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 4000,
workspaceId: "workspace-b",
projectSlug: "repo-b",
scriptName: "api",
}),
).toThrow("Service proxy hostname collision");
expect(serviceProxy.getRouteEntry("api--repo-a.localhost")).toMatchObject({ port: 3000 });
expect(serviceProxy.getRouteEntry("api--repo-b.localhost")).toBeNull();
});
it("rejects canonical-to-public-alias collisions", () => {
const serviceProxy = new ServiceProxyRouteRegistry();
serviceProxy.registerRoute({
hostname: "api--repo.localhost",
publicHostname: "api.services.example.com",
publicBaseUrl: "https://services.example.com",
port: 3000,
workspaceId: "workspace-a",
projectSlug: "repo",
scriptName: "api",
});
expect(() =>
serviceProxy.registerRoute({
hostname: "api.services.example.com",
port: 4000,
workspaceId: "workspace-b",
projectSlug: "other",
scriptName: "api",
}),
).toThrow("Service proxy hostname collision");
expect(serviceProxy.getRouteEntry("api--repo.localhost")).toMatchObject({ port: 3000 });
expect(serviceProxy.getRouteEntry("api.services.example.com")).toMatchObject({ port: 3000 });
});
it("allows same workspace/script replacement", () => {
const serviceProxy = createServiceProxySubsystem({ logger });
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 3000,
});
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: 4000,
});
expect(serviceProxy.getHealthTargetForHostname("api--repo.localhost")).toMatchObject({
port: 4000,
});
});
});
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -229,7 +229,7 @@ interface SessionForTestOptions {
workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
projectRegistry?: Partial<SessionOptions["projectRegistry"]>;
terminalManager?: SessionOptions["terminalManager"];
scriptRouteStore?: SessionOptions["scriptRouteStore"];
serviceProxy?: SessionOptions["serviceProxy"];
scriptRuntimeStore?: SessionOptions["scriptRuntimeStore"];
getDaemonTcpPort?: () => number | null;
getDaemonTcpHost?: () => string | null;
@@ -311,7 +311,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
terminalManager: options.terminalManager ?? null,
providerSnapshotManager:
options.providerSnapshotManager ?? createProviderSnapshotManagerStub().manager,
scriptRouteStore: options.scriptRouteStore,
serviceProxy: options.serviceProxy,
scriptRuntimeStore: options.scriptRuntimeStore,
getDaemonTcpPort: options.getDaemonTcpPort,
getDaemonTcpHost: options.getDaemonTcpHost,
@@ -3549,7 +3549,7 @@ describe("session workspace script handling", () => {
workspaceGitService,
workspaceRegistry,
terminalManager: { subscribeTerminalsChanged: vi.fn(() => () => {}) },
scriptRouteStore: { listRoutesForWorkspace: vi.fn(() => []) },
serviceProxy: { listRoutesForWorkspace: vi.fn(() => []) },
scriptRuntimeStore: { listForWorkspace: vi.fn(() => []) },
getDaemonTcpPort: () => 6767,
getDaemonTcpHost: () => "127.0.0.1",
+12 -12
View File
@@ -192,7 +192,7 @@ import {
import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js";
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import type { ScriptRouteStore } from "./script-proxy.js";
import type { ServiceProxySubsystem } from "./service-proxy.js";
import {
checkoutResolvedBranch,
type CheckoutExistingBranchResult,
@@ -588,7 +588,7 @@ export interface SessionOptions {
tts: Resolvable<TextToSpeechProvider | null>;
terminalManager: TerminalManager | null;
providerSnapshotManager: ProviderSnapshotManager;
scriptRouteStore?: ScriptRouteStore;
serviceProxy?: ServiceProxySubsystem;
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
workspaceSetupSnapshots?: Map<string, WorkspaceSetupSnapshot>;
onBranchChanged?: (
@@ -803,7 +803,7 @@ export class Session {
private readonly terminalManager: TerminalManager | null;
private readonly providerSnapshotManager: ProviderSnapshotManager;
private unsubscribeProviderSnapshotEvents: (() => void) | null = null;
private readonly scriptRouteStore: ScriptRouteStore | null;
private readonly serviceProxy: ServiceProxySubsystem | null;
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
private readonly onBranchChanged?: (
workspaceId: string,
@@ -881,7 +881,7 @@ export class Session {
tts,
terminalManager,
providerSnapshotManager,
scriptRouteStore,
serviceProxy,
scriptRuntimeStore,
workspaceSetupSnapshots,
onBranchChanged,
@@ -966,7 +966,7 @@ export class Session {
logger: this.sessionLogger,
});
this.providerSnapshotManager = providerSnapshotManager;
this.scriptRouteStore = scriptRouteStore ?? null;
this.serviceProxy = serviceProxy ?? null;
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
this.workspaceSetupSnapshots = workspaceSetupSnapshots ?? new Map();
this.onBranchChanged = onBranchChanged;
@@ -6295,12 +6295,12 @@ export class Session {
activityAt: null,
diffStat,
scripts:
this.scriptRouteStore && this.scriptRuntimeStore
this.serviceProxy && this.scriptRuntimeStore
? buildWorkspaceScriptPayloads({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.cwd,
paseoConfig: readPaseoConfigForProjection(workspace.cwd, this.sessionLogger),
routeStore: this.scriptRouteStore,
serviceProxy: this.serviceProxy,
runtimeStore: this.scriptRuntimeStore,
daemonPort: this.getDaemonTcpPort?.() ?? null,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
@@ -7124,14 +7124,14 @@ export class Session {
workspaceId: string,
workspaceDirectory: string,
): WorkspaceDescriptorPayload["scripts"] {
if (!this.scriptRouteStore || !this.scriptRuntimeStore) {
if (!this.serviceProxy || !this.scriptRuntimeStore) {
return [];
}
return buildWorkspaceScriptPayloads({
workspaceId,
workspaceDirectory,
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, this.sessionLogger),
routeStore: this.scriptRouteStore,
serviceProxy: this.serviceProxy,
runtimeStore: this.scriptRuntimeStore,
daemonPort: this.getDaemonTcpPort?.() ?? null,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
@@ -7182,7 +7182,7 @@ export class Session {
request: StartWorkspaceScriptRequest,
): Promise<void> {
try {
if (!this.terminalManager || !this.scriptRouteStore || !this.scriptRuntimeStore) {
if (!this.terminalManager || !this.serviceProxy || !this.scriptRuntimeStore) {
throw new Error("Workspace scripts are not available on this daemon");
}
@@ -7201,7 +7201,7 @@ export class Session {
daemonPort: this.getDaemonTcpPort?.() ?? null,
daemonListenHost: this.getDaemonTcpHost?.() ?? null,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
routeStore: this.scriptRouteStore,
serviceProxy: this.serviceProxy,
runtimeStore: this.scriptRuntimeStore,
terminalManager: this.terminalManager,
logger: this.sessionLogger,
@@ -7348,7 +7348,7 @@ export class Session {
sessionLogger: this.sessionLogger,
terminalManager: this.terminalManager,
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
scriptRouteStore: this.scriptRouteStore,
serviceProxy: this.serviceProxy,
scriptRuntimeStore: this.scriptRuntimeStore,
getDaemonTcpPort: this.getDaemonTcpPort,
getDaemonTcpHost: this.getDaemonTcpHost,
@@ -2,10 +2,11 @@ import { describe, expect, test, vi } from "vitest";
import path from "node:path";
import type pino from "pino";
import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
import { ScriptRouteStore } from "./script-proxy.js";
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js";
import { Session, type SessionOptions } from "./session.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js";
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
import { createTestLogger } from "../test-utils/test-logger.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type {
WorkspaceGitListener,
@@ -102,7 +103,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
oldBranch: string | null,
newBranch: string | null,
) => void;
scriptRouteStore?: ScriptRouteStore;
serviceProxy?: ServiceProxySubsystem;
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
}): {
session: Session;
@@ -234,7 +235,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
tts: null,
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
terminalManager: null,
scriptRouteStore: options?.scriptRouteStore,
serviceProxy: options?.serviceProxy,
scriptRuntimeStore: options?.scriptRuntimeStore,
onBranchChanged: options?.onBranchChanged,
getDaemonTcpPort: () => 6767,
@@ -425,12 +426,12 @@ describe("workspace git watch targets", () => {
});
test("updates running service script URLs when the git branch changes", async () => {
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "app.old-branch.paseo.localhost",
const serviceProxy = createServiceProxySubsystem({ logger: createTestLogger() });
serviceProxy.registerWorkspaceService({
port: 4321,
workspaceId: "ws-10",
projectSlug: "paseo",
branchName: "old-branch",
scriptName: "app",
});
const runtimeStore = new WorkspaceScriptRuntimeStore();
@@ -444,12 +445,12 @@ describe("workspace git watch targets", () => {
});
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
serviceProxy,
onRoutesChanged: vi.fn(),
});
const { session, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests(
{
scriptRouteStore: routeStore,
serviceProxy,
scriptRuntimeStore: runtimeStore,
onBranchChanged: handleBranchChange,
},
@@ -474,18 +475,19 @@ describe("workspace git watch targets", () => {
}),
);
expect(routeStore.listRoutesForWorkspace("ws-10")).toEqual([
expect(serviceProxy.getWorkspaceHealthTargets("ws-10")).toEqual([
expect.objectContaining({
hostname: "app.new-branch.paseo.localhost",
projectSlug: "paseo",
hostname: "app--new-branch--paseo.localhost",
scriptName: "app",
}),
]);
expect(sessionAny.buildWorkspaceScriptPayloadSnapshot("ws-10", "/tmp/repo")).toEqual([
expect.objectContaining({
scriptName: "app",
hostname: "app.new-branch.paseo.localhost",
proxyUrl: "http://app.new-branch.paseo.localhost:6767",
hostname: "app--new-branch--paseo.localhost",
localProxyUrl: "http://app--new-branch--paseo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://app--new-branch--paseo.localhost:6767",
}),
]);
@@ -33,6 +33,7 @@ interface TestPaseoDaemonOptions {
dictationFinalTimeoutMs?: number;
auth?: PaseoDaemonConfig["auth"];
pushNotificationSender?: PushNotificationSender;
serviceProxy?: PaseoDaemonConfig["serviceProxy"];
}
export interface TestPaseoDaemon {
@@ -161,6 +162,7 @@ async function prepareTestDaemonConfig(
appBaseUrl: "https://app.paseo.sh",
auth: options.auth,
pushNotificationSender: options.pushNotificationSender,
serviceProxy: options.serviceProxy,
openai: options.openai,
speech: options.speech,
voiceLlmProvider: options.voiceLlmProvider ?? null,
@@ -92,10 +92,10 @@ export function asWorkspaceGitService(stub: {
return createStub<SessionOptions["workspaceGitService"]>(stub);
}
export function asScriptRouteStore(stub: {
[K in keyof SessionOptions["scriptRouteStore"]]?: unknown;
}): SessionOptions["scriptRouteStore"] {
return createStub<SessionOptions["scriptRouteStore"]>(stub);
export function asServiceProxy(stub: {
[K in keyof SessionOptions["serviceProxy"]]?: unknown;
}): SessionOptions["serviceProxy"] {
return createStub<SessionOptions["serviceProxy"]>(stub);
}
export function asWorkspaceScriptRuntimeStore(stub: {
@@ -35,7 +35,7 @@ import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.
import { PushTokenStore } from "./push/token-store.js";
import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js";
import type { ScriptHealthState } from "./script-health-monitor.js";
import type { ScriptRouteStore } from "./script-proxy.js";
import type { ServiceProxySubsystem } from "./service-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
@@ -357,7 +357,7 @@ export class VoiceAssistantWebSocketServer {
private readonly mcpBaseUrl: string | null;
private speech!: SpeechService | null;
private terminalManager!: TerminalManager | null;
private scriptRouteStore!: ScriptRouteStore | null;
private serviceProxy!: ServiceProxySubsystem | null;
private scriptRuntimeStore!: WorkspaceScriptRuntimeStore | null;
private getDaemonTcpPort!: (() => number | null) | null;
private getDaemonTcpHost!: (() => string | null) | null;
@@ -405,7 +405,7 @@ export class VoiceAssistantWebSocketServer {
loopService?: LoopService,
scheduleService?: ScheduleService,
checkoutDiffManager?: CheckoutDiffManager,
scriptRouteStore?: ScriptRouteStore | null,
serviceProxy?: ServiceProxySubsystem | null,
scriptRuntimeStore?: WorkspaceScriptRuntimeStore | null,
onBranchChanged?: (
workspaceId: string,
@@ -465,7 +465,7 @@ export class VoiceAssistantWebSocketServer {
terminalManager,
dictation,
onLifecycleIntent,
scriptRouteStore,
serviceProxy,
scriptRuntimeStore,
onBranchChanged,
getDaemonTcpPort,
@@ -514,7 +514,7 @@ export class VoiceAssistantWebSocketServer {
terminalManager: TerminalManager | null | undefined;
dictation: { finalTimeoutMs?: number } | undefined;
onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | undefined;
scriptRouteStore: ScriptRouteStore | null | undefined;
serviceProxy: ServiceProxySubsystem | null | undefined;
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null | undefined;
onBranchChanged:
| ((workspaceId: string, oldBranch: string | null, newBranch: string | null) => void)
@@ -528,7 +528,7 @@ export class VoiceAssistantWebSocketServer {
this.terminalManager = params.terminalManager ?? null;
this.dictation = params.dictation ?? null;
this.onLifecycleIntent = params.onLifecycleIntent ?? null;
this.scriptRouteStore = params.scriptRouteStore ?? null;
this.serviceProxy = params.serviceProxy ?? null;
this.scriptRuntimeStore = params.scriptRuntimeStore ?? null;
this.onBranchChanged = params.onBranchChanged ?? null;
this.getDaemonTcpPort = params.getDaemonTcpPort ?? null;
@@ -884,7 +884,7 @@ export class VoiceAssistantWebSocketServer {
tts: () => this.speech?.resolveTts() ?? null,
terminalManager: this.terminalManager,
providerSnapshotManager: this.providerSnapshotManager,
scriptRouteStore: this.scriptRouteStore ?? undefined,
serviceProxy: this.serviceProxy ?? undefined,
scriptRuntimeStore: this.scriptRuntimeStore ?? undefined,
workspaceSetupSnapshots: this.workspaceSetupSnapshots,
onBranchChanged: this.onBranchChanged ?? undefined,
@@ -65,9 +65,9 @@ describe("buildWorkspaceServiceEnv", () => {
).toEqual({
HOST: "127.0.0.1",
PASEO_PORT: "5173",
PASEO_URL: "http://daemon.paseo.localhost:6767",
PASEO_URL: "http://daemon--paseo.localhost:6767",
PASEO_SERVICE_DAEMON_PORT: "5173",
PASEO_SERVICE_DAEMON_URL: "http://daemon.paseo.localhost:6767",
PASEO_SERVICE_DAEMON_URL: "http://daemon--paseo.localhost:6767",
});
});
@@ -84,9 +84,9 @@ describe("buildWorkspaceServiceEnv", () => {
).toEqual({
HOST: "127.0.0.1",
PASEO_PORT: "5173",
PASEO_URL: "http://daemon.feature-x.paseo.localhost:6767",
PASEO_URL: "http://daemon--feature-x--paseo.localhost:6767",
PASEO_SERVICE_DAEMON_PORT: "5173",
PASEO_SERVICE_DAEMON_URL: "http://daemon.feature-x.paseo.localhost:6767",
PASEO_SERVICE_DAEMON_URL: "http://daemon--feature-x--paseo.localhost:6767",
});
});
@@ -137,11 +137,32 @@ describe("buildWorkspaceServiceEnv", () => {
).toEqual({
HOST: "127.0.0.1",
PASEO_PORT: "5173",
PASEO_URL: "http://web.feature-x.paseo.localhost:6767",
PASEO_URL: "http://web--feature-x--paseo.localhost:6767",
PASEO_SERVICE_API_PORT: "4000",
PASEO_SERVICE_API_URL: "http://api.feature-x.paseo.localhost:6767",
PASEO_SERVICE_API_URL: "http://api--feature-x--paseo.localhost:6767",
PASEO_SERVICE_WEB_PORT: "5173",
PASEO_SERVICE_WEB_URL: "http://web.feature-x.paseo.localhost:6767",
PASEO_SERVICE_WEB_URL: "http://web--feature-x--paseo.localhost:6767",
});
});
it("uses public service URLs when a public base URL is configured", () => {
expect(
buildWorkspaceServiceEnv({
scriptName: "web",
projectSlug: "paseo",
branchName: "feature-x",
daemonPort: 6767,
daemonListenHost: null,
serviceProxyPublicBaseUrl: "https://services.example.com",
peers: [
{ scriptName: "api", port: 4000 },
{ scriptName: "web", port: 5173 },
],
}),
).toMatchObject({
PASEO_URL: "https://web--feature-x--paseo.services.example.com",
PASEO_SERVICE_API_URL: "https://api--feature-x--paseo.services.example.com",
PASEO_SERVICE_WEB_URL: "https://web--feature-x--paseo.services.example.com",
});
});
@@ -1,4 +1,4 @@
import { buildPublicScriptProxyUrl, buildScriptHostname } from "../utils/script-hostname.js";
import { projectServiceProxyUrls } from "./service-proxy.js";
export interface WorkspaceServicePeer {
scriptName: string;
@@ -81,25 +81,13 @@ interface BuildServiceProxyUrlOptions {
}
function buildServiceProxyUrl(options: BuildServiceProxyUrlOptions): string | null {
if (options.serviceProxyPublicBaseUrl) {
return buildPublicScriptProxyUrl({
return projectServiceProxyUrls({
projectSlug: options.projectSlug,
branchName: options.branchName,
scriptName: options.scriptName,
daemonPort: options.daemonPort,
publicBaseUrl: options.serviceProxyPublicBaseUrl,
});
}
if (options.daemonPort === null || options.daemonPort === undefined) {
return null;
}
const hostname = buildScriptHostname({
projectSlug: options.projectSlug,
branchName: options.branchName,
scriptName: options.scriptName,
});
return `http://${hostname}:${options.daemonPort}`;
}).proxyUrl;
}
function isLoopbackListenHost(host: string | null | undefined): boolean {
@@ -450,7 +450,7 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
branchName: "feature-peer-env",
scriptName,
daemonPort: 6767,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
}),
@@ -465,16 +465,24 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
const apiEnv = readEnvFile(apiEnvPath);
const webEnv = readEnvFile(webEnvPath);
expect(apiEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767");
expect(apiEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767");
expect(apiEnv.PASEO_SERVICE_API_URL).toBe(
"http://api--feature-peer-env--repo.localhost:6767",
);
expect(apiEnv.PASEO_SERVICE_WEB_URL).toBe(
"http://web--feature-peer-env--repo.localhost:6767",
);
expect(apiEnv.PASEO_SERVICE_API_PORT).toEqual(expect.stringMatching(/^\d+$/));
expect(apiEnv.PASEO_SERVICE_WEB_PORT).toEqual(expect.stringMatching(/^\d+$/));
expect(apiEnv.PASEO_URL).toBe(apiEnv.PASEO_SERVICE_API_URL);
expect(apiEnv.PASEO_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT);
expect(apiEnv).not.toHaveProperty("PORT");
expect(webEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767");
expect(webEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767");
expect(webEnv.PASEO_SERVICE_API_URL).toBe(
"http://api--feature-peer-env--repo.localhost:6767",
);
expect(webEnv.PASEO_SERVICE_WEB_URL).toBe(
"http://web--feature-peer-env--repo.localhost:6767",
);
expect(webEnv.PASEO_SERVICE_API_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT);
expect(webEnv.PASEO_SERVICE_WEB_PORT).toBe(apiEnv.PASEO_SERVICE_WEB_PORT);
expect(webEnv.PASEO_URL).toBe(webEnv.PASEO_SERVICE_WEB_URL);
@@ -487,14 +495,14 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
expect(Number.isInteger(webPort)).toBe(true);
expect(routeStore.listRoutes()).toEqual([
{
hostname: "api.feature-peer-env.repo.localhost",
hostname: "api--feature-peer-env--repo.localhost",
port: apiPort,
workspaceId: repoDir,
projectSlug: "repo",
scriptName: "api",
},
{
hostname: "web.feature-peer-env.repo.localhost",
hostname: "web--feature-peer-env--repo.localhost",
port: webPort,
workspaceId: repoDir,
projectSlug: "repo",
@@ -420,13 +420,13 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(createTerminalCalls[0]?.env?.PASEO_PORT).toEqual(expect.any(String));
expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1");
expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe(
"http://api.feature-socket-service.repo.localhost:6767",
"http://api--feature-socket-service--repo.localhost:6767",
);
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_PORT).toBe(
createTerminalCalls[0]?.env?.PASEO_PORT,
);
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_URL).toBe(
"http://api.feature-socket-service.repo.localhost:6767",
"http://api--feature-socket-service--repo.localhost:6767",
);
}
@@ -450,7 +450,7 @@ describe("runAsyncWorktreeBootstrap", () => {
String(plannedAppServerPort),
);
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_URL).toBe(
"http://app-server.feature-socket-service.repo.localhost:6767",
"http://app-server--feature-socket-service--repo.localhost:6767",
);
}
@@ -497,7 +497,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-socket-service",
scriptName: "web",
daemonPort: null,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
});
@@ -541,7 +541,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-exit",
scriptName: "typecheck",
daemonPort: null,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
});
@@ -580,7 +580,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-rerun",
scriptName: "typecheck",
daemonPort: null,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
});
@@ -600,7 +600,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-rerun",
scriptName: "typecheck",
daemonPort: null,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
});
@@ -659,7 +659,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-existing-terminal",
scriptName: "typecheck",
daemonPort: null,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
});
@@ -701,7 +701,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-terminal-exit",
scriptName: "typecheck",
daemonPort: null,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
});
@@ -738,7 +738,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-duplicate",
scriptName: "typecheck",
daemonPort: null,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
});
@@ -751,7 +751,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-duplicate",
scriptName: "typecheck",
daemonPort: null,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
}),
@@ -786,7 +786,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-socket-service",
scriptName: "api",
daemonPort: 6767,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
});
@@ -794,7 +794,7 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(result.scriptName).toBe("api");
expect(routeStore.listRoutes()).toEqual([
{
hostname: "api.feature-socket-service.repo.localhost",
hostname: "api--feature-socket-service--repo.localhost",
port: expect.any(Number),
workspaceId: repoDir,
projectSlug: "repo",
@@ -813,6 +813,60 @@ describe("runAsyncWorktreeBootstrap", () => {
});
});
it("spawns services with public aliases and public service URLs", async () => {
commitPaseoScripts(
{
api: {
type: "service",
command: "npm run api",
},
"app-server": {
type: "service",
command: "npm run app",
},
},
"add public service script config",
);
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
const createTerminalCalls: CreateTerminalCall[] = [];
const terminalRecords: StubTerminalRecord[] = [];
const result = await spawnWorkspaceScript({
repoRoot: repoDir,
workspaceId: repoDir,
projectSlug: "repo",
branchName: "feature-public-service",
scriptName: "api",
daemonPort: 6767,
serviceProxyPublicBaseUrl: "https://services.example.com",
serviceProxy: routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
});
expect(result.hostname).toBe("api--feature-public-service--repo.localhost");
expect(
routeStore.getRouteEntry("api--feature-public-service--repo.services.example.com"),
).toMatchObject({
hostname: "api--feature-public-service--repo.localhost",
publicHostname: "api--feature-public-service--repo.services.example.com",
publicBaseUrl: "https://services.example.com",
workspaceId: repoDir,
scriptName: "api",
});
expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe(
"https://api--feature-public-service--repo.services.example.com",
);
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_URL).toBe(
"https://api--feature-public-service--repo.services.example.com",
);
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_URL).toBe(
"https://app-server--feature-public-service--repo.services.example.com",
);
});
it("refreshes a stopped service port on respawn and updates the route", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
@@ -852,7 +906,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-respawn-service",
scriptName: "api",
daemonPort: 6767,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
});
@@ -864,7 +918,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-respawn-service",
scriptName: "worker",
daemonPort: 6767,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
});
@@ -889,7 +943,7 @@ describe("runAsyncWorktreeBootstrap", () => {
lifecycle: "stopped",
exitCode: 0,
});
expect(routeStore.getRouteEntry("api.feature-respawn-service.repo.localhost")).toBeNull();
expect(routeStore.getRouteEntry("api--feature-respawn-service--repo.localhost")).toBeNull();
const secondResult = await spawnWorkspaceScript({
repoRoot: repoDir,
@@ -898,7 +952,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-respawn-service",
scriptName: "api",
daemonPort: 6767,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
});
@@ -911,8 +965,8 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(secondPort).not.toBe(firstPort);
expect(secondPort).toEqual(expect.any(Number));
expect(createTerminalCalls[2]?.env?.PASEO_SERVICE_WORKER_PORT).toBe(String(workerPort));
expect(routeStore.getRouteEntry("api.feature-respawn-service.repo.localhost")).toMatchObject({
hostname: "api.feature-respawn-service.repo.localhost",
expect(routeStore.getRouteEntry("api--feature-respawn-service--repo.localhost")).toMatchObject({
hostname: "api--feature-respawn-service--repo.localhost",
port: secondPort,
workspaceId: repoDir,
projectSlug: "repo",
@@ -955,20 +1009,20 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-before-rename",
scriptName: "api",
daemonPort: 6767,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager,
});
const updateRoutesForBranchChange = createBranchChangeRouteHandler({
routeStore,
serviceProxy: routeStore,
onRoutesChanged: () => {},
});
updateRoutesForBranchChange(repoDir, "feature-before-rename", "feature-after-rename");
expect(routeStore.listRoutesForWorkspace(repoDir)).toEqual([
expect.objectContaining({
hostname: "api.feature-after-rename.repo.localhost",
hostname: "api--feature-after-rename--repo.localhost",
scriptName: "api",
}),
]);
@@ -1020,7 +1074,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-collision-service",
scriptName: "app-server",
daemonPort: 6767,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls),
}),
@@ -1029,7 +1083,7 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(createTerminalCalls).toHaveLength(0);
expect(routeStore.listRoutes()).toEqual([]);
expect(
routeStore.getRouteEntry("app-server.feature-collision-service.repo.localhost"),
routeStore.getRouteEntry("app-server--feature-collision-service--repo.localhost"),
).toBeNull();
writeFileSync(
@@ -1055,7 +1109,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-collision-service",
scriptName: "app-server",
daemonPort: 6767,
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls),
});
@@ -1108,7 +1162,7 @@ describe("runAsyncWorktreeBootstrap", () => {
scriptName: "web",
daemonPort: 6767,
daemonListenHost: "100.64.0.20",
routeStore,
serviceProxy: routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls),
});
@@ -1116,7 +1170,7 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(createTerminalCalls).toHaveLength(1);
expect(createTerminalCalls[0]?.env?.HOST).toBe("0.0.0.0");
expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe(
"http://web.feature-remote-service.repo.localhost:6767",
"http://web--feature-remote-service--repo.localhost:6767",
);
});
});
@@ -2,7 +2,6 @@ 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 { buildPublicScriptHostname, buildScriptHostname } from "../utils/script-hostname.js";
import {
getScriptConfigs,
getWorktreeTerminalSpecs,
@@ -17,7 +16,7 @@ import {
type WorktreeSetupCommandResult,
type WorktreeRuntimeEnv,
} from "../utils/worktree.js";
import { findFreePort, type ScriptRouteStore } from "./script-proxy.js";
import { findFreePort, type ServiceProxySubsystem } from "./service-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js";
import {
@@ -700,7 +699,7 @@ interface SpawnWorkspaceScriptOptions {
daemonPort?: number | null;
daemonListenHost?: string | null;
serviceProxyPublicBaseUrl?: string | null;
routeStore: ScriptRouteStore;
serviceProxy: ServiceProxySubsystem;
runtimeStore: WorkspaceScriptRuntimeStore;
terminalManager: TerminalManager;
logger?: Logger;
@@ -724,7 +723,7 @@ async function setupServiceScriptRoute(params: {
daemonListenHost: string | null | undefined;
serviceProxyPublicBaseUrl: string | null | undefined;
existingRuntimeEntry: ReturnType<WorkspaceScriptRuntimeStore["get"]>;
routeStore: ScriptRouteStore;
serviceProxy: ServiceProxySubsystem;
}): Promise<ServiceScriptSetupResult> {
const {
scriptConfigs,
@@ -737,17 +736,8 @@ async function setupServiceScriptRoute(params: {
daemonListenHost,
serviceProxyPublicBaseUrl,
existingRuntimeEntry,
routeStore,
serviceProxy,
} = 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) {
@@ -794,16 +784,15 @@ async function setupServiceScriptRoute(params: {
peers,
});
routeStore.registerRoute({
hostname,
publicHostname,
publicBaseUrl: serviceProxyPublicBaseUrl ?? null,
const registeredRoute = serviceProxy.registerWorkspaceService({
port,
workspaceId,
projectSlug,
branchName,
scriptName,
publicBaseUrl: serviceProxyPublicBaseUrl ?? null,
});
return { hostname, port, env };
return { hostname: registeredRoute.hostname, port, env };
}
async function acquireWorkspaceScriptTerminal(params: {
@@ -843,7 +832,7 @@ export async function spawnWorkspaceScript(
daemonPort,
daemonListenHost,
serviceProxyPublicBaseUrl,
routeStore,
serviceProxy,
runtimeStore,
terminalManager,
logger,
@@ -886,7 +875,7 @@ export async function spawnWorkspaceScript(
daemonListenHost,
serviceProxyPublicBaseUrl,
existingRuntimeEntry,
routeStore,
serviceProxy,
});
hostname = serviceSetup.hostname;
port = serviceSetup.port;
@@ -923,7 +912,7 @@ export async function spawnWorkspaceScript(
disposeLifecycleListeners = null;
if (input.removeRoute && hostname) {
routeStore.removeRouteForWorkspaceScript({ workspaceId, scriptName });
serviceProxy.removeWorkspaceService({ workspaceId, scriptName });
}
runtimeStore.set({
workspaceId,
@@ -991,7 +980,7 @@ export async function spawnWorkspaceScript(
} catch (error) {
disposeLifecycleListeners?.();
if (routeRegistered && hostname) {
routeStore.removeRoute(hostname);
serviceProxy.removeServiceRoutesByHostnames([hostname]);
}
if (runtimeRegistered) {
runtimeStore.remove({ workspaceId, scriptName });
@@ -1014,12 +1003,12 @@ export async function spawnWorkspaceScript(
export function teardownWorktreeScripts(options: {
hostnames: string[];
routeStore: ScriptRouteStore;
serviceProxy: Pick<ServiceProxySubsystem, "removeServiceRoutesByHostnames">;
logger: Logger;
}): void {
const { hostnames, routeStore, logger } = options;
const { hostnames, serviceProxy, logger } = options;
serviceProxy.removeServiceRoutesByHostnames(hostnames);
for (const hostname of hostnames) {
routeStore.removeRoute(hostname);
logger.info({ hostname }, "Removed script proxy route");
}
}
@@ -107,7 +107,7 @@ function createWorkflowForRequestTest(options: {
sessionLogger: createLogger(),
terminalManager: null,
archiveWorkspaceRecord: async () => {},
scriptRouteStore: null,
serviceProxy: null,
scriptRuntimeStore: null,
getDaemonTcpPort: null,
getDaemonTcpHost: null,
@@ -416,7 +416,7 @@ describe("create-agent worktree setup boundary", () => {
sessionLogger: createLogger(),
terminalManager: null,
archiveWorkspaceRecord: async () => {},
scriptRouteStore: null,
serviceProxy: null,
scriptRuntimeStore: null,
getDaemonTcpPort: null,
getDaemonTcpHost: null,
@@ -20,7 +20,7 @@ import {
getWorktreeSetupProgressResults,
} from "./worktree-bootstrap.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { ScriptRouteStore } from "./script-proxy.js";
import type { ServiceProxySubsystem } from "./service-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { GitHubService } from "../services/github-service.js";
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
@@ -103,7 +103,7 @@ interface CreatePaseoWorktreeInBackgroundDependencies {
sessionLogger: Logger;
terminalManager: TerminalManager | null;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
scriptRouteStore: ScriptRouteStore | null;
serviceProxy: ServiceProxySubsystem | null;
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
getDaemonTcpPort: (() => number | null) | null;
getDaemonTcpHost: (() => string | null) | null;
@@ -13,7 +13,7 @@ describe("buildScriptHostname", () => {
branchName: null,
scriptName: "web",
}),
).toBe("web.paseo.localhost");
).toBe("web--paseo.localhost");
});
it("omits the branch label for main and master", () => {
@@ -23,14 +23,14 @@ describe("buildScriptHostname", () => {
branchName: "main",
scriptName: "web",
}),
).toBe("web.paseo.localhost");
).toBe("web--paseo.localhost");
expect(
buildScriptHostname({
projectSlug: "paseo",
branchName: "master",
scriptName: "web",
}),
).toBe("web.paseo.localhost");
).toBe("web--paseo.localhost");
});
it("builds non-default branch hostnames with script, branch, and project labels", () => {
@@ -40,7 +40,7 @@ describe("buildScriptHostname", () => {
branchName: "feature-auth",
scriptName: "web",
}),
).toBe("web.feature-auth.paseo.localhost");
).toBe("web--feature-auth--paseo.localhost");
});
it("slugifies script, default branch project, and non-default branch labels", () => {
@@ -50,7 +50,7 @@ describe("buildScriptHostname", () => {
branchName: "Feature/Auth Flow",
scriptName: "Web/API @ Dev",
}),
).toBe("web-api-dev.feature-auth-flow.paseo-app.localhost");
).toBe("web-api-dev--feature-auth-flow--paseo-app.localhost");
});
it("accepts already slugified labels because slugify is idempotent", () => {
@@ -60,7 +60,7 @@ describe("buildScriptHostname", () => {
branchName: "feature-auth-flow",
scriptName: "web-api-dev",
}),
).toBe("web-api-dev.feature-auth-flow.paseo-app.localhost");
).toBe("web-api-dev--feature-auth-flow--paseo-app.localhost");
});
it("uses untitled as the hostname-label fallback when labels collapse to empty", () => {
@@ -70,7 +70,7 @@ describe("buildScriptHostname", () => {
branchName: "***",
scriptName: "---",
}),
).toBe("untitled.untitled.untitled.localhost");
).toBe("untitled--untitled--untitled.localhost");
});
});
+9 -48
View File
@@ -1,6 +1,10 @@
import { slugify } from "./worktree.js";
import {
buildLocalServiceHostname,
buildPublicServiceHostname,
buildPublicServiceProxyUrl,
} from "../server/service-proxy.js";
const MAX_DNS_LABEL_LENGTH = 63;
// Compatibility boundary for older tests/imports; new service proxy code owns hostname rules.
interface BuildScriptHostnameOptions {
projectSlug: string;
@@ -12,64 +16,21 @@ 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,
scriptName,
}: BuildScriptHostnameOptions): string {
const serviceHostnameLabel = toHostnameLabel(scriptName);
const projectHostnameLabel = toHostnameLabel(projectSlug);
const isDefaultBranch = branchName === null || branchName === "main" || branchName === "master";
if (isDefaultBranch) {
return `${serviceHostnameLabel}.${projectHostnameLabel}.localhost`;
}
return `${serviceHostnameLabel}.${toHostnameLabel(branchName)}.${projectHostnameLabel}.localhost`;
return buildLocalServiceHostname({ projectSlug, branchName, scriptName });
}
export function buildPublicScriptHostname({
publicBaseUrl,
...script
}: BuildPublicScriptHostnameOptions): string {
const base = new URL(publicBaseUrl);
return `${toPublicServiceLabel(script)}.${base.hostname}`;
return buildPublicServiceHostname({ publicBaseUrl, ...script });
}
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}`;
return buildPublicServiceProxyUrl(options);
}
@@ -97,12 +97,15 @@
"type": "object",
"properties": {
"enabled": {
"description": "Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.",
"type": "boolean"
},
"listen": {
"description": "Optional service-only listener address. Presence enables the standalone service proxy listener.",
"type": "string"
},
"publicBaseUrl": {
"description": "Optional public base URL for service host aliases.",
"type": "string",
"format": "uri"
}
+5 -5
View File
@@ -110,10 +110,10 @@ Omit `port` to let Paseo auto-assign one. Bind your process to `$PASEO_PORT` rat
Every service is reachable through the daemon at a deterministic hostname:
```
http://<script>.<branch>.<project>.localhost:<daemon-port>
http://<script>--<branch>--<project>.localhost:<daemon-port>
# on the default branch, the branch label is dropped:
http://<script>.<project>.localhost:<daemon-port>
http://<script>--<project>.localhost:<daemon-port>
```
`*.localhost` resolves to `127.0.0.1` on modern systems, so these URLs work out of the box. The proxy supports WebSocket upgrades.
@@ -124,11 +124,11 @@ Services launched from the same workspace see each other's ports and proxy URLs.
```
PASEO_PORT=3000 # this service's port
PASEO_URL=http://web.my-app.localhost:6767 # this service's proxy URL
PASEO_URL=http://web--my-app.localhost:6767 # this service's proxy URL
PASEO_SERVICE_API_PORT=51732
PASEO_SERVICE_API_URL=http://api.my-app.localhost:6767
PASEO_SERVICE_API_URL=http://api--my-app.localhost:6767
PASEO_SERVICE_WEB_PORT=3000
PASEO_SERVICE_WEB_URL=http://web.my-app.localhost:6767
PASEO_SERVICE_WEB_URL=http://web--my-app.localhost:6767
```
Script names are upper-cased and non-alphanumerics become `_`. Point your frontend at `$PASEO_SERVICE_API_URL` instead of hard-coding a port.