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 | | 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_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_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. | | `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
| `HOST` | Bind host for the service process. | | `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. `<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: `PORT` is not injected by default. If a framework requires `PORT`, set it in the command:
+30 -28
View File
@@ -1,6 +1,6 @@
# Service Proxy # 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 ## 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: 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: 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: **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 ## Configuration
@@ -35,7 +35,6 @@ Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
"version": 1, "version": 1,
"daemon": { "daemon": {
"serviceProxy": { "serviceProxy": {
"enabled": true,
"listen": "0.0.0.0:8080", "listen": "0.0.0.0:8080",
"publicBaseUrl": "https://paseoapps.my.domain.com" "publicBaseUrl": "https://paseoapps.my.domain.com"
} }
@@ -43,11 +42,12 @@ Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
} }
``` ```
| Field | Required | Description | | Field | Required | Description |
| --------------- | -------- | ----------------------------------------------------------------------------------------------- | | --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | No | Enables the proxy. Defaults to `true` if `publicBaseUrl` is set, `false` otherwise. | | `listen` | No | Starts a separate service-only listener at this address. If omitted, services are still reachable on the daemon listener via localhost hosts. |
| `listen` | No | Address and port the proxy listens on. Defaults to `127.0.0.1:6868`. | | `publicBaseUrl` | No | Adds public service host aliases and public service links. If omitted, links use localhost addresses only. |
| `publicBaseUrl` | No | Base URL used to generate 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 ## DNS and reverse proxy setup
@@ -63,28 +63,30 @@ 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. 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.
Nginx example: Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services.
```nginx Nginx example:
server {
listen 443 ssl;
server_name *.paseoapps.my.domain.com;
location / { ```nginx
proxy_pass http://10.1.1.1:8080; server {
proxy_set_header Host $host; listen 443 ssl;
} server_name *.paseoapps.my.domain.com;
}
``` location / {
proxy_pass http://10.1.1.1:8080;
proxy_set_header Host $host;
}
}
```
## Environment variables ## Environment variables
The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`: The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`:
| Variable | Description | | Variable | Description |
| ------------------------------------- | ----------------------------------- | | ------------------------------------- | ------------------------------------------------------------------------- |
| `PASEO_SERVICE_PROXY_ENABLED` | `true` or `false` | | `PASEO_SERVICE_PROXY_ENABLED` | Compatibility shim; `false` suppresses optional public/listen layers only |
| `PASEO_SERVICE_PROXY_LISTEN` | Listen address, e.g. `0.0.0.0:8080` | | `PASEO_SERVICE_PROXY_LISTEN` | Starts the optional service-only listener, e.g. `0.0.0.0:8080` |
| `PASEO_SERVICE_PROXY_PUBLIC_BASE_URL` | Public base URL | | `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 = { const runningService: WorkspaceScriptPayload = {
scriptName: "web", scriptName: "web",
type: "service", type: "service",
hostname: "web.feature.paseo.localhost", hostname: "web--feature--paseo.localhost",
port: 3000, 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", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null, exitCode: null,
@@ -27,8 +29,8 @@ describe("resolveWorkspaceScriptLink", () => {
expect( expect(
resolveLink({ type: "directTcp", endpoint: "localhost:6767", display: "localhost:6767" }), resolveLink({ type: "directTcp", endpoint: "localhost:6767", display: "localhost:6767" }),
).toEqual({ ).toEqual({
openUrl: "http://web.feature.paseo.localhost:6767", openUrl: "http://web--feature--paseo.localhost:6767",
labelUrl: "http://web.feature.paseo.localhost:6767", labelUrl: "http://web--feature--paseo.localhost:6767",
}); });
}); });
@@ -36,8 +38,8 @@ describe("resolveWorkspaceScriptLink", () => {
expect( expect(
resolveLink({ type: "directSocket", endpoint: "/tmp/paseo.sock", display: "socket" }), resolveLink({ type: "directSocket", endpoint: "/tmp/paseo.sock", display: "socket" }),
).toEqual({ ).toEqual({
openUrl: "http://web.feature.paseo.localhost:6767", openUrl: "http://web--feature--paseo.localhost:6767",
labelUrl: "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" }), resolveLink({ type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" }),
).toEqual({ ).toEqual({
openUrl: null, 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 }; return { openUrl: null, labelUrl: script.proxyUrl };
} }
const proxyUrlIsLocalOnly = isLocalOnlyUrl(script.proxyUrl); const localProxyUrl = script.localProxyUrl ?? script.proxyUrl;
const publicProxyUrl =
if (!proxyUrlIsLocalOnly) { script.publicProxyUrl ?? (!isLocalOnlyUrl(script.proxyUrl) ? script.proxyUrl : null);
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl }; const preferredProxyUrl = publicProxyUrl ?? localProxyUrl ?? script.proxyUrl;
}
if (activeConnection.type === "relay") { 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") { if (activeConnection.type === "directSocket" || activeConnection.type === "directPipe") {
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl }; return { openUrl: localProxyUrl, labelUrl: localProxyUrl };
} }
try { try {
const { host } = parseHostPort(activeConnection.endpoint); const { host } = parseHostPort(activeConnection.endpoint);
if (isLoopbackHost(host)) { if (isLoopbackHost(host)) {
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl }; return { openUrl: localProxyUrl, labelUrl: localProxyUrl };
} }
} catch { } catch {
return { openUrl: null, labelUrl: script.proxyUrl }; return { openUrl: null, labelUrl: preferredProxyUrl };
}
if (publicProxyUrl) {
return { openUrl: publicProxyUrl, labelUrl: publicProxyUrl };
} }
if (script.port === null) { if (script.port === null) {
@@ -80,6 +86,6 @@ export function resolveWorkspaceScriptLink(input: {
const directUrl = buildDirectServiceUrl(activeConnection.endpoint, script.port); const directUrl = buildDirectServiceUrl(activeConnection.endpoint, script.port);
return { return {
openUrl: directUrl, 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"), type: z.enum(["script", "service"]).optional().default("service"),
hostname: z.string(), hostname: z.string(),
port: z.number().int().positive().nullable(), port: z.number().int().positive().nullable(),
localProxyUrl: z.string().nullable().optional(),
publicProxyUrl: z.string().nullable().optional(),
proxyUrl: z.string().nullable().optional().default(null), proxyUrl: z.string().nullable().optional().default(null),
lifecycle: WorkspaceScriptLifecycleSchema, lifecycle: WorkspaceScriptLifecycleSchema,
health: WorkspaceScriptHealthSchema.nullable(), health: WorkspaceScriptHealthSchema.nullable(),
@@ -5,6 +5,7 @@ import {
SessionInboundMessageSchema, SessionInboundMessageSchema,
SessionOutboundMessageSchema, SessionOutboundMessageSchema,
WorkspaceDescriptorPayloadSchema, WorkspaceDescriptorPayloadSchema,
WorkspaceScriptPayloadSchema,
} from "./messages.js"; } from "./messages.js";
describe("workspace message schemas", () => { 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", () => { test("parses workspace_setup_progress payload", () => {
const parsed = SessionOutboundMessageSchema.parse({ const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_setup_progress", type: "workspace_setup_progress",
@@ -1,14 +1,18 @@
import os from "node:os"; import os from "node:os";
import http from "node:http";
import path from "node:path"; import path from "node:path";
import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { mkdir, mkdtemp, rm } from "node:fs/promises";
import pino from "pino"; import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js"; import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
import { hashDaemonPassword } from "./auth.js";
import { generateLocalPairingOffer } from "./pairing-offer.js"; import { generateLocalPairingOffer } from "./pairing-offer.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
import { isPlatform } from "../test-utils/platform.js"; import { isPlatform } from "../test-utils/platform.js";
import { findFreePort } from "./service-proxy.js";
describe("paseo daemon bootstrap", () => { describe("paseo daemon bootstrap", () => {
afterEach(() => { 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 () => { test("redacts Agent MCP debug request credentials and bodies", async () => {
const logLines: string[] = []; const logLines: string[] = [];
const logger = pino( const logger = pino(
+190 -238
View File
@@ -126,11 +126,7 @@ import type {
ProviderOverride, ProviderOverride,
} from "./agent/provider-launch-config.js"; } from "./agent/provider-launch-config.js";
import type { PersistedConfig } from "./persisted-config.js"; import type { PersistedConfig } from "./persisted-config.js";
import { import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js";
ScriptRouteStore,
createScriptProxyMiddleware,
createScriptProxyUpgradeHandler,
} from "./script-proxy.js";
import { ScriptHealthMonitor } from "./script-health-monitor.js"; import { ScriptHealthMonitor } from "./script-health-monitor.js";
import { createScriptStatusEmitter } from "./script-status-projection.js"; import { createScriptStatusEmitter } from "./script-status-projection.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
@@ -250,9 +246,8 @@ export interface PaseoDaemonConfig {
relayUseTls?: boolean; relayUseTls?: boolean;
relayPublicUseTls?: boolean; relayPublicUseTls?: boolean;
serviceProxy?: { serviceProxy?: {
enabled: boolean;
listen: string;
publicBaseUrl: string | null; publicBaseUrl: string | null;
standaloneListen: string | null;
}; };
appBaseUrl?: string; appBaseUrl?: string;
auth?: DaemonAuthConfig; auth?: DaemonAuthConfig;
@@ -282,7 +277,7 @@ export interface PaseoDaemon {
agentManager: AgentManager; agentManager: AgentManager;
agentStorage: AgentStorage; agentStorage: AgentStorage;
terminalManager: TerminalManager; terminalManager: TerminalManager;
scriptRouteStore: ScriptRouteStore; serviceProxy: ServiceProxySubsystem;
scriptRuntimeStore: WorkspaceScriptRuntimeStore; scriptRuntimeStore: WorkspaceScriptRuntimeStore;
start(): Promise<void>; start(): Promise<void>;
stop(): Promise<void>; stop(): Promise<void>;
@@ -336,24 +331,25 @@ export async function createPaseoDaemon(
let boundListenTarget: ListenTarget | null = null; let boundListenTarget: ListenTarget | null = null;
let workspaceRegistry: FileBackedWorkspaceRegistry | null = null; let workspaceRegistry: FileBackedWorkspaceRegistry | null = null;
const scriptRouteStore = new ScriptRouteStore(); const serviceProxyPublicBaseUrl = config.serviceProxy?.publicBaseUrl
? config.serviceProxy.publicBaseUrl
: null;
const serviceProxy = createServiceProxySubsystem({
logger,
publicBaseUrl: serviceProxyPublicBaseUrl,
});
const scriptRuntimeStore = new WorkspaceScriptRuntimeStore(); const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
const configuredHostnames = config.hostnames ?? config.allowedHosts; const configuredHostnames = config.hostnames ?? config.allowedHosts;
const serviceProxyPublicBaseUrl =
config.serviceProxy?.enabled && config.serviceProxy.publicBaseUrl
? config.serviceProxy.publicBaseUrl
: null;
let wsServer: VoiceAssistantWebSocketServer | null = null; let wsServer: VoiceAssistantWebSocketServer | null = null;
let serviceProxyHttpServer: ReturnType<typeof createHTTPServer> | null = null;
let serviceProxyListenTarget: ListenTarget | null = null; let serviceProxyListenTarget: ListenTarget | null = null;
const scriptHealthMonitor = new ScriptHealthMonitor({ const scriptHealthMonitor = new ScriptHealthMonitor({
routeStore: scriptRouteStore, serviceProxy,
onChange: createScriptStatusEmitter({ onChange: createScriptStatusEmitter({
sessions: () => sessions: () =>
wsServer?.listActiveSessions().map((session) => ({ wsServer?.listActiveSessions().map((session) => ({
emit: (message) => session.emitServerMessage(message), emit: (message) => session.emitServerMessage(message),
})) ?? [], })) ?? [],
routeStore: scriptRouteStore, serviceProxy,
runtimeStore: scriptRuntimeStore, runtimeStore: scriptRuntimeStore,
daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
resolveWorkspaceDirectory: async (workspaceId) => resolveWorkspaceDirectory: async (workspaceId) =>
@@ -363,13 +359,18 @@ export async function createPaseoDaemon(
}), }),
}); });
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore: scriptRouteStore, serviceProxy,
onRoutesChanged: (workspaceId) => { onRoutesChanged: (workspaceId) => {
scriptHealthMonitor.invalidateWorkspace(workspaceId); scriptHealthMonitor.invalidateWorkspace(workspaceId);
}, },
logger, 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). // Host allowlist / DNS rebinding protection (vite-like semantics).
// For non-TCP (unix sockets), skip host validation. // For non-TCP (unix sockets), skip host validation.
if (listenTarget.type === "tcp") { 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 // Serve static files from public directory
app.use("/public", express.static(staticDir)); app.use("/public", express.static(staticDir));
@@ -507,34 +503,10 @@ export async function createPaseoDaemon(
// VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that // VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that
// script-bound upgrades are forwarded first. The handler is a no-op for // script-bound upgrades are forwarded first. The handler is a no-op for
// requests that don't match a registered script route. // requests that don't match a registered script route.
const scriptProxyUpgradeHandler = createScriptProxyUpgradeHandler({ httpServer.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: true }));
routeStore: scriptRouteStore,
logger,
});
httpServer.on("upgrade", scriptProxyUpgradeHandler);
if (config.serviceProxy?.enabled) { if (config.serviceProxy?.standaloneListen) {
const serviceProxyTarget = parseListenString(config.serviceProxy.listen); serviceProxyListenTarget = parseListenString(config.serviceProxy.standaloneListen);
const serviceProxyApp = express();
serviceProxyApp.set("trust proxy", true);
serviceProxyApp.use(createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger }));
serviceProxyApp.use((_req, res) => {
res.status(404).send("404 Not Found");
});
serviceProxyHttpServer = createHTTPServer(serviceProxyApp);
const serviceProxyUpgradeHandler = createScriptProxyUpgradeHandler({
routeStore: scriptRouteStore,
logger,
});
serviceProxyHttpServer.on("upgrade", (req, socket, head) => {
const hostHeader = req.headers.host;
if (!hostHeader || !scriptRouteStore.findRoute(hostHeader)) {
socket.destroy();
return;
}
serviceProxyUpgradeHandler(req, socket as import("node:net").Socket, head);
});
serviceProxyListenTarget = serviceProxyTarget;
} }
const agentStorage = new AgentStorage(config.agentStoragePath, logger); const agentStorage = new AgentStorage(config.agentStoragePath, logger);
@@ -767,7 +739,7 @@ export async function createPaseoDaemon(
sessionLogger: logger, sessionLogger: logger,
terminalManager, terminalManager,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal, archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
scriptRouteStore, serviceProxy,
scriptRuntimeStore, scriptRuntimeStore,
getDaemonTcpPort: () => getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
@@ -914,194 +886,185 @@ export async function createPaseoDaemon(
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening"); logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
const start = async () => { const start = async () => {
// Start main HTTP server let mainStarted = false;
await new Promise<void>((resolve, reject) => { try {
const onError = (err: Error) => { if (serviceProxyListenTarget) {
httpServer.off("listening", onListening); const boundServiceProxyTarget = await serviceProxy.startStandalone({
reject(err); listenTarget: serviceProxyListenTarget,
}; });
const onListening = () => { serviceProxyListenTarget = boundServiceProxyTarget;
httpServer.off("error", onError); logger.info(
const logAndResolve = async () => { {
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer); listen: formatListenTarget(serviceProxyListenTarget),
const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null; publicBaseUrl: serviceProxyPublicBaseUrl,
agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl; elapsed: elapsed(),
agentManager.setMcpBaseUrl(agentMcpBaseUrl); },
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => { "Service proxy listening",
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null); );
});
daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
});
const relayEnabled = config.relayEnabled ?? true;
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
const relayUseTls = config.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443";
const relayPublicUseTls = config.relayPublicUseTls ?? relayUseTls;
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
if (boundListenTarget.type === "tcp") {
logger.info(
{
host: boundListenTarget.host,
port: boundListenTarget.port,
authRequired: !!config.auth?.password,
elapsed: elapsed(),
},
`Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`,
);
} else {
logger.info(
{
path: boundListenTarget.path,
authRequired: !!config.auth?.password,
elapsed: elapsed(),
},
`Server listening on ${boundListenTarget.path}`,
);
}
if (config.auth?.password) {
logger.info("Daemon password authentication enabled");
}
wsServer = new VoiceAssistantWebSocketServer(
httpServer,
logger,
serverId,
agentManager,
agentStorage,
downloadTokenStore,
config.paseoHome,
daemonConfigStore,
mcpBaseUrl,
{ allowedOrigins, hostnames: configuredHostnames },
config.auth,
speechService,
terminalManager,
{
finalTimeoutMs: config.dictationFinalTimeoutMs,
},
daemonVersion,
(intent) => {
try {
config.onLifecycleIntent?.(intent);
} catch (error) {
logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
}
},
projectRegistry,
workspaceRegistry,
chatService,
loopService,
scheduleService,
checkoutDiffManager,
scriptRouteStore,
scriptRuntimeStore,
handleBranchChange,
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
workspaceGitService,
github,
config.pushNotificationSender,
providerSnapshotManager,
{
listen: formatListenTarget(boundListenTarget ?? listenTarget),
worktreesRoot: config.worktreesRoot,
relay: {
enabled: relayEnabled,
endpoint: relayEndpoint,
publicEndpoint: relayPublicEndpoint,
useTls: relayUseTls,
publicUseTls: relayPublicUseTls,
},
},
serviceProxyPublicBaseUrl,
);
if (relayEnabled) {
const offer = await createConnectionOfferV2({
serverId,
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
relay: {
endpoint: relayPublicEndpoint,
useTls: relayPublicUseTls,
},
});
encodeOfferToFragmentUrl({ offer, appBaseUrl });
relayTransport?.stop().catch(() => undefined);
relayTransport = startRelayTransport({
logger,
attachSocket: (ws, metadata) => {
if (!wsServer) {
throw new Error("WebSocket server not initialized");
}
return wsServer.attachExternalSocket(ws, metadata);
},
relayEndpoint,
relayUseTls,
serverId,
daemonKeyPair: daemonKeyPair.keyPair,
});
}
};
logAndResolve().then(resolve, reject);
};
httpServer.once("error", onError);
httpServer.once("listening", onListening);
if (listenTarget.type === "tcp") {
httpServer.listen(listenTarget.port, listenTarget.host);
} else {
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
unlinkSync(listenTarget.path);
}
httpServer.listen(listenTarget.path);
} }
});
if (serviceProxyHttpServer && serviceProxyListenTarget) { // Start main HTTP server
const proxyServer = serviceProxyHttpServer;
const proxyTarget = serviceProxyListenTarget;
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => { const onError = (err: Error) => {
proxyServer.off("listening", onListening); httpServer.off("listening", onListening);
reject(err); reject(err);
}; };
const onListening = () => { const onListening = () => {
proxyServer.off("error", onError); httpServer.off("error", onError);
serviceProxyListenTarget = resolveBoundListenTarget(proxyTarget, proxyServer); mainStarted = true;
logger.info( const logAndResolve = async () => {
{ boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
listen: formatListenTarget(serviceProxyListenTarget), const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
publicBaseUrl: serviceProxyPublicBaseUrl, agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
elapsed: elapsed(), agentManager.setMcpBaseUrl(agentMcpBaseUrl);
}, daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
"Service proxy listening", agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
); });
resolve(); daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
}; agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
proxyServer.once("error", onError); });
proxyServer.once("listening", onListening); const relayEnabled = config.relayEnabled ?? true;
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
const relayUseTls = config.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443";
const relayPublicUseTls = config.relayPublicUseTls ?? relayUseTls;
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
if (proxyTarget.type === "tcp") { if (boundListenTarget.type === "tcp") {
proxyServer.listen(proxyTarget.port, proxyTarget.host); logger.info(
{
host: boundListenTarget.host,
port: boundListenTarget.port,
authRequired: !!config.auth?.password,
elapsed: elapsed(),
},
`Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`,
);
} else {
logger.info(
{
path: boundListenTarget.path,
authRequired: !!config.auth?.password,
elapsed: elapsed(),
},
`Server listening on ${boundListenTarget.path}`,
);
}
if (config.auth?.password) {
logger.info("Daemon password authentication enabled");
}
wsServer = new VoiceAssistantWebSocketServer(
httpServer,
logger,
serverId,
agentManager,
agentStorage,
downloadTokenStore,
config.paseoHome,
daemonConfigStore,
mcpBaseUrl,
{ allowedOrigins, hostnames: configuredHostnames },
config.auth,
speechService,
terminalManager,
{
finalTimeoutMs: config.dictationFinalTimeoutMs,
},
daemonVersion,
(intent) => {
try {
config.onLifecycleIntent?.(intent);
} catch (error) {
logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
}
},
projectRegistry,
workspaceRegistry,
chatService,
loopService,
scheduleService,
checkoutDiffManager,
serviceProxy,
scriptRuntimeStore,
handleBranchChange,
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
workspaceGitService,
github,
config.pushNotificationSender,
providerSnapshotManager,
{
listen: formatListenTarget(boundListenTarget ?? listenTarget),
worktreesRoot: config.worktreesRoot,
relay: {
enabled: relayEnabled,
endpoint: relayEndpoint,
publicEndpoint: relayPublicEndpoint,
useTls: relayUseTls,
publicUseTls: relayPublicUseTls,
},
},
serviceProxyPublicBaseUrl,
);
if (relayEnabled) {
const offer = await createConnectionOfferV2({
serverId,
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
relay: {
endpoint: relayPublicEndpoint,
useTls: relayPublicUseTls,
},
});
encodeOfferToFragmentUrl({ offer, appBaseUrl });
relayTransport?.stop().catch(() => undefined);
relayTransport = startRelayTransport({
logger,
attachSocket: (ws, metadata) => {
if (!wsServer) {
throw new Error("WebSocket server not initialized");
}
return wsServer.attachExternalSocket(ws, metadata);
},
relayEndpoint,
relayUseTls,
serverId,
daemonKeyPair: daemonKeyPair.keyPair,
});
}
};
logAndResolve().then(resolve, reject);
};
httpServer.once("error", onError);
httpServer.once("listening", onListening);
if (listenTarget.type === "tcp") {
httpServer.listen(listenTarget.port, listenTarget.host);
} else { } else {
if (proxyTarget.type === "socket" && existsSync(proxyTarget.path)) { if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
unlinkSync(proxyTarget.path); unlinkSync(listenTarget.path);
} }
proxyServer.listen(proxyTarget.path); httpServer.listen(listenTarget.path);
} }
}); });
}
// Start speech service after listening so synchronous Sherpa native // Start speech service after listening so synchronous Sherpa native
// model loading doesn't block the server from accepting connections. // model loading doesn't block the server from accepting connections.
speechService.start(); speechService.start();
scriptHealthMonitor.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 () => { const stop = async () => {
@@ -1118,18 +1081,7 @@ export async function createPaseoDaemon(
if (wsServer) { if (wsServer) {
await wsServer.close(); await wsServer.close();
} }
if (serviceProxyHttpServer) { await serviceProxy.stopStandalone();
serviceProxyHttpServer.closeAllConnections();
await new Promise<void>((resolve) => {
serviceProxyHttpServer?.close(() => resolve());
});
if (
serviceProxyListenTarget?.type === "socket" &&
existsSync(serviceProxyListenTarget.path)
) {
unlinkSync(serviceProxyListenTarget.path);
}
}
// Force-drop remaining sockets so httpServer.close() resolves promptly. // Force-drop remaining sockets so httpServer.close() resolves promptly.
// We've already closed wsServer (which sent ws-layer close frames) and // We've already closed wsServer (which sent ws-layer close frames) and
// stopped every other service, so anything still attached is a TCP // stopped every other service, so anything still attached is a TCP
@@ -1152,7 +1104,7 @@ export async function createPaseoDaemon(
agentManager, agentManager,
agentStorage, agentStorage,
terminalManager, terminalManager,
scriptRouteStore, serviceProxy,
scriptRuntimeStore, scriptRuntimeStore,
start, start,
stop, stop,
@@ -104,9 +104,38 @@ describe("daemon service proxy config", () => {
}); });
expect(config.serviceProxy).toEqual({ expect(config.serviceProxy).toEqual({
enabled: true,
listen: "127.0.0.1:6868",
publicBaseUrl: "https://env.example.com", 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,
}); });
}); });
+18 -17
View File
@@ -24,7 +24,6 @@ import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostn
const DEFAULT_PORT = 6767; const DEFAULT_PORT = 6767;
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443"; const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
const DEFAULT_APP_BASE_URL = "https://app.paseo.sh"; 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 { function parseBooleanEnv(value: string | undefined): boolean | undefined {
if (value === undefined) { if (value === undefined) {
@@ -154,9 +153,8 @@ interface ResolvedRelay {
} }
interface ResolvedServiceProxy { interface ResolvedServiceProxy {
enabled: boolean;
listen: string;
publicBaseUrl: string | null; publicBaseUrl: string | null;
standaloneListen: string | null;
} }
function resolveTlsFromEnv( function resolveTlsFromEnv(
@@ -220,21 +218,24 @@ function resolveServiceProxyConfig(
env: NodeJS.ProcessEnv, env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>, persisted: ReturnType<typeof loadPersistedConfig>,
): ResolvedServiceProxy { ): ResolvedServiceProxy {
const publicBaseUrl = resolveServiceProxyPublicBaseUrl( const enabledShim =
env.PASEO_SERVICE_PROXY_PUBLIC_BASE_URL ?? parseBooleanEnv(env.PASEO_SERVICE_PROXY_ENABLED) ?? persisted.daemon?.serviceProxy?.enabled;
persisted.daemon?.serviceProxy?.publicBaseUrl ?? // COMPAT(serviceProxyEnabled): added 2026-06-02, remove after 2026-12-02.
null, // `enabled=false` used to disable the separate service proxy listener. Localhost
); // service proxying is now always enabled; this only suppresses optional layers.
const enabled = const optionalLayersEnabled = enabledShim !== false;
parseBooleanEnv(env.PASEO_SERVICE_PROXY_ENABLED) ?? const publicBaseUrl = optionalLayersEnabled
persisted.daemon?.serviceProxy?.enabled ?? ? resolveServiceProxyPublicBaseUrl(
publicBaseUrl !== null; env.PASEO_SERVICE_PROXY_PUBLIC_BASE_URL ??
const listen = persisted.daemon?.serviceProxy?.publicBaseUrl ??
env.PASEO_SERVICE_PROXY_LISTEN ?? null,
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( function resolveVoiceLlmConfig(
@@ -239,6 +239,9 @@ export const PersistedConfigSchema = z
.optional(), .optional(),
serviceProxy: z serviceProxy: z
.object({ .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(), enabled: z.boolean().optional(),
listen: z.string().optional(), listen: z.string().optional(),
publicBaseUrl: z.string().url().optional(), publicBaseUrl: z.string().url().optional(),
@@ -162,7 +162,7 @@ describe("ScriptHealthMonitor", () => {
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, serviceProxy: routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
probeTimeoutMs: 100, probeTimeoutMs: 100,
@@ -214,7 +214,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, serviceProxy: routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
probeTimeoutMs: 100, probeTimeoutMs: 100,
@@ -261,7 +261,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, serviceProxy: routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
probeTimeoutMs: 100, probeTimeoutMs: 100,
@@ -292,7 +292,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, serviceProxy: routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
probeTimeoutMs: 100, probeTimeoutMs: 100,
@@ -343,7 +343,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, serviceProxy: routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
probeTimeoutMs: 100, probeTimeoutMs: 100,
@@ -393,7 +393,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, serviceProxy: routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
probeTimeoutMs: 100, probeTimeoutMs: 100,
@@ -450,7 +450,7 @@ describe("ScriptHealthMonitor", () => {
branchName: null, branchName: null,
scriptName, scriptName,
daemonPort: null, daemonPort: null,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager: createStubTerminalManager( terminalManager: createStubTerminalManager(
createTerminalCalls, createTerminalCalls,
@@ -462,7 +462,7 @@ describe("ScriptHealthMonitor", () => {
expect(createTerminalCalls).toHaveLength(2); expect(createTerminalCalls).toHaveLength(2);
expect(routeStore.listRoutes()).toEqual([ expect(routeStore.listRoutes()).toEqual([
{ {
hostname: "api.repo.localhost", hostname: "api--repo.localhost",
port: service.port, port: service.port,
workspaceId: workspace.repoDir, workspaceId: workspace.repoDir,
projectSlug: "repo", projectSlug: "repo",
@@ -472,7 +472,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, serviceProxy: routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
probeTimeoutMs: 100, probeTimeoutMs: 100,
@@ -487,7 +487,7 @@ describe("ScriptHealthMonitor", () => {
expect(onChange).toHaveBeenCalledWith(workspace.repoDir, [ expect(onChange).toHaveBeenCalledWith(workspace.repoDir, [
{ {
scriptName: "api", scriptName: "api",
hostname: "api.repo.localhost", hostname: "api--repo.localhost",
port: service.port, port: service.port,
health: "healthy", health: "healthy",
}, },
@@ -524,7 +524,7 @@ describe("ScriptHealthMonitor", () => {
const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>(); const onChange = vi.fn<(workspaceId: string, services: ScriptHealthEntry[]) => void>();
const monitor = new ScriptHealthMonitor({ const monitor = new ScriptHealthMonitor({
routeStore, serviceProxy: routeStore,
onChange, onChange,
pollIntervalMs: 1_000, pollIntervalMs: 1_000,
probeTimeoutMs: 100, probeTimeoutMs: 100,
@@ -1,5 +1,5 @@
import net from "node:net"; 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"; export type ScriptHealthState = "pending" | "healthy" | "unhealthy";
@@ -18,7 +18,7 @@ interface RouteHealthState {
} }
export class ScriptHealthMonitor { export class ScriptHealthMonitor {
private readonly routeStore: ScriptRouteStore; private readonly serviceProxy: ServiceProxySubsystem;
private readonly onChange: (workspaceId: string, scripts: ScriptHealthEntry[]) => void; private readonly onChange: (workspaceId: string, scripts: ScriptHealthEntry[]) => void;
private readonly pollIntervalMs: number; private readonly pollIntervalMs: number;
private readonly probeTimeoutMs: number; private readonly probeTimeoutMs: number;
@@ -31,21 +31,21 @@ export class ScriptHealthMonitor {
private pollInFlight = false; private pollInFlight = false;
constructor({ constructor({
routeStore, serviceProxy,
onChange, onChange,
pollIntervalMs = 3_000, pollIntervalMs = 3_000,
probeTimeoutMs = 500, probeTimeoutMs = 500,
graceMs = 5_000, graceMs = 5_000,
failuresBeforeStopped = 2, failuresBeforeStopped = 2,
}: { }: {
routeStore: ScriptRouteStore; serviceProxy: ServiceProxySubsystem;
onChange: (workspaceId: string, scripts: ScriptHealthEntry[]) => void; onChange: (workspaceId: string, scripts: ScriptHealthEntry[]) => void;
pollIntervalMs?: number; pollIntervalMs?: number;
probeTimeoutMs?: number; probeTimeoutMs?: number;
graceMs?: number; graceMs?: number;
failuresBeforeStopped?: number; failuresBeforeStopped?: number;
}) { }) {
this.routeStore = routeStore; this.serviceProxy = serviceProxy;
this.onChange = onChange; this.onChange = onChange;
this.pollIntervalMs = pollIntervalMs; this.pollIntervalMs = pollIntervalMs;
this.probeTimeoutMs = probeTimeoutMs; this.probeTimeoutMs = probeTimeoutMs;
@@ -59,7 +59,7 @@ export class ScriptHealthMonitor {
} }
const now = Date.now(); const now = Date.now();
for (const route of this.routeStore.listRoutes()) { for (const route of this.serviceProxy.getHealthCheckTargets()) {
this.getOrCreateState(route, now); this.getOrCreateState(route, now);
} }
@@ -93,7 +93,7 @@ export class ScriptHealthMonitor {
this.pollInFlight = true; this.pollInFlight = true;
try { try {
const routes = this.routeStore.listRoutes(); const routes = this.serviceProxy.getHealthCheckTargets();
const activeHostnames = new Set(routes.map((route) => route.hostname)); const activeHostnames = new Set(routes.map((route) => route.hostname));
const changedWorkspaceIds = new Set<string>(); const changedWorkspaceIds = new Set<string>();
const now = Date.now(); const now = Date.now();
@@ -142,7 +142,7 @@ export class ScriptHealthMonitor {
} }
private getOrCreateState( private getOrCreateState(
route: Pick<ScriptRouteEntry, "hostname" | "workspaceId">, route: Pick<ServiceProxyHealthTarget, "hostname" | "workspaceId">,
registeredAt: number, registeredAt: number,
): RouteHealthState { ): RouteHealthState {
const existing = this.routeStates.get(route.hostname); const existing = this.routeStates.get(route.hostname);
@@ -171,7 +171,7 @@ export class ScriptHealthMonitor {
} }
private buildWorkspaceScriptList(workspaceId: string): ScriptHealthEntry[] { 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); const state = this.routeStates.get(route.hostname);
if (!state) { if (!state) {
return []; return [];
@@ -186,7 +186,7 @@ export class ScriptHealthMonitor {
return state.health; return state.health;
} }
const route = this.routeStore.getRouteEntry(hostname); const route = this.serviceProxy.getHealthTargetForHostname(hostname);
if (!route) { if (!route) {
return null; return null;
} }
@@ -195,7 +195,7 @@ export class ScriptHealthMonitor {
} }
private toScriptHealthEntry( private toScriptHealthEntry(
route: ScriptRouteEntry, route: ServiceProxyHealthTarget,
health: ScriptHealthEntry["health"], health: ScriptHealthEntry["health"],
): ScriptHealthEntry { ): ScriptHealthEntry {
return { return {
+8 -476
View File
@@ -1,484 +1,16 @@
import { it, expect, afterEach } from "vitest"; import { describe, expect, it } 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 { import {
ScriptRouteStore,
createScriptProxyMiddleware, createScriptProxyMiddleware,
createScriptProxyUpgradeHandler, createScriptProxyUpgradeHandler,
findFreePort, findFreePort,
ScriptRouteStore,
} from "./script-proxy.js"; } from "./script-proxy.js";
const logger = pino({ level: "silent" }); describe("script-proxy compatibility re-exports", () => {
it("keeps the legacy imports available", () => {
// --------------------------------------------------------------------------- expect(createScriptProxyMiddleware).toEqual(expect.any(Function));
// Helpers for cleanup expect(createScriptProxyUpgradeHandler).toEqual(expect.any(Function));
// --------------------------------------------------------------------------- expect(findFreePort).toEqual(expect.any(Function));
expect(new ScriptRouteStore()).toEqual(expect.any(Object));
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",
}); });
}); });
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"; export {
import net from "node:net"; createScriptProxyMiddleware,
import type { IncomingMessage } from "node:http"; createScriptProxyUpgradeHandler,
import type { Logger } from "pino"; findFreePort,
import type { RequestHandler } from "express"; ScriptRouteStore,
} from "./service-proxy.js";
// --------------------------------------------------------------------------- export type { ScriptRoute, ScriptRouteEntry } from "./service-proxy.js";
// 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);
});
}
@@ -76,22 +76,22 @@ describe("script-route-branch-handler", () => {
it("updates routes on branch rename by removing old hostnames and registering new ones", () => { it("updates routes on branch rename by removing old hostnames and registering new ones", () => {
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "api.feature-auth.paseo.localhost", hostname: "api--feature-auth--paseo.localhost",
port: 3001, port: 3001,
scriptName: "api", scriptName: "api",
}); });
const onRoutesChanged = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, serviceProxy: routeStore,
onRoutesChanged, onRoutesChanged,
}); });
handleBranchChange("workspace-a", "feature/auth", "feature/billing"); handleBranchChange("workspace-a", "feature/auth", "feature/billing");
expect(routeStore.findRoute("api.feature-auth.paseo.localhost")).toBeNull(); expect(routeStore.findRoute("api--feature-auth--paseo.localhost")).toBeNull();
expect(routeStore.findRoute("api.feature-billing.paseo.localhost")).toEqual({ expect(routeStore.findRoute("api--feature-billing--paseo.localhost")).toEqual({
hostname: "api.feature-billing.paseo.localhost", hostname: "api--feature-billing--paseo.localhost",
port: 3001, port: 3001,
}); });
}); });
@@ -100,7 +100,7 @@ describe("script-route-branch-handler", () => {
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
const onRoutesChanged = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, serviceProxy: routeStore,
onRoutesChanged, onRoutesChanged,
}); });
@@ -113,14 +113,14 @@ describe("script-route-branch-handler", () => {
it("is a no-op when the resolved hostnames do not change", () => { it("is a no-op when the resolved hostnames do not change", () => {
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "api.paseo.localhost", hostname: "api--paseo.localhost",
port: 3001, port: 3001,
scriptName: "api", scriptName: "api",
}); });
const onRoutesChanged = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, serviceProxy: routeStore,
onRoutesChanged, onRoutesChanged,
}); });
@@ -128,7 +128,7 @@ describe("script-route-branch-handler", () => {
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([ expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
{ {
hostname: "api.paseo.localhost", hostname: "api--paseo.localhost",
port: 3001, port: 3001,
workspaceId: "workspace-a", workspaceId: "workspace-a",
projectSlug: "paseo", projectSlug: "paseo",
@@ -141,14 +141,14 @@ describe("script-route-branch-handler", () => {
it("triggers shared reprojection after a route change", () => { it("triggers shared reprojection after a route change", () => {
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "api.feature-auth.paseo.localhost", hostname: "api--feature-auth--paseo.localhost",
port: 3001, port: 3001,
scriptName: "api", scriptName: "api",
}); });
const onRoutesChanged = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, serviceProxy: routeStore,
onRoutesChanged, onRoutesChanged,
}); });
@@ -160,7 +160,7 @@ describe("script-route-branch-handler", () => {
it("updates public route aliases from the stored public base URL", () => { it("updates public route aliases from the stored public base URL", () => {
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "api.feature-auth.paseo.localhost", hostname: "api--feature-auth--paseo.localhost",
publicHostname: "api--feature-auth--paseo.services.example.com", publicHostname: "api--feature-auth--paseo.services.example.com",
publicBaseUrl: "https://services.example.com:8443", publicBaseUrl: "https://services.example.com:8443",
port: 3001, port: 3001,
@@ -169,7 +169,7 @@ describe("script-route-branch-handler", () => {
const onRoutesChanged = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, serviceProxy: routeStore,
onRoutesChanged, 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-auth--paseo.services.example.com")).toBeNull();
expect(routeStore.findRoute("api--feature-billing--paseo.services.example.com")).toEqual({ 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, port: 3001,
}); });
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([ 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", publicHostname: "api--feature-billing--paseo.services.example.com",
publicBaseUrl: "https://services.example.com:8443", publicBaseUrl: "https://services.example.com:8443",
port: 3001, port: 3001,
@@ -196,17 +196,17 @@ describe("script-route-branch-handler", () => {
it("updates all services for a workspace when multiple routes are registered", () => { it("updates all services for a workspace when multiple routes are registered", () => {
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "api.feature-auth.paseo.localhost", hostname: "api--feature-auth--paseo.localhost",
port: 3001, port: 3001,
scriptName: "api", scriptName: "api",
}); });
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "web.feature-auth.paseo.localhost", hostname: "web--feature-auth--paseo.localhost",
port: 3002, port: 3002,
scriptName: "web", scriptName: "web",
}); });
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "docs.docs-app.localhost", hostname: "docs--docs-app.localhost",
port: 3003, port: 3003,
workspaceId: "workspace-b", workspaceId: "workspace-b",
projectSlug: "docs-app", projectSlug: "docs-app",
@@ -215,7 +215,7 @@ describe("script-route-branch-handler", () => {
const onRoutesChanged = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, serviceProxy: routeStore,
onRoutesChanged, onRoutesChanged,
}); });
@@ -223,14 +223,14 @@ describe("script-route-branch-handler", () => {
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([ expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
{ {
hostname: "api.feature-billing.paseo.localhost", hostname: "api--feature-billing--paseo.localhost",
port: 3001, port: 3001,
workspaceId: "workspace-a", workspaceId: "workspace-a",
projectSlug: "paseo", projectSlug: "paseo",
scriptName: "api", scriptName: "api",
}, },
{ {
hostname: "web.feature-billing.paseo.localhost", hostname: "web--feature-billing--paseo.localhost",
port: 3002, port: 3002,
workspaceId: "workspace-a", workspaceId: "workspace-a",
projectSlug: "paseo", projectSlug: "paseo",
@@ -239,7 +239,7 @@ describe("script-route-branch-handler", () => {
]); ]);
expect(routeStore.listRoutesForWorkspace("workspace-b")).toEqual([ expect(routeStore.listRoutesForWorkspace("workspace-b")).toEqual([
{ {
hostname: "docs.docs-app.localhost", hostname: "docs--docs-app.localhost",
port: 3003, port: 3003,
workspaceId: "workspace-b", workspaceId: "workspace-b",
projectSlug: "docs-app", projectSlug: "docs-app",
@@ -251,14 +251,14 @@ describe("script-route-branch-handler", () => {
it("does not emit a status update when no changes are needed", () => { it("does not emit a status update when no changes are needed", () => {
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "web.paseo.localhost", hostname: "web--paseo.localhost",
port: 3002, port: 3002,
scriptName: "web", scriptName: "web",
}); });
const onRoutesChanged = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, serviceProxy: routeStore,
onRoutesChanged, onRoutesChanged,
}); });
@@ -279,7 +279,7 @@ describe("script-route-branch-handler", () => {
}); });
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
registerRoute(routeStore, { registerRoute(routeStore, {
hostname: "api.feature-auth.repo.localhost", hostname: "api--feature-auth--repo.localhost",
port: 3001, port: 3001,
workspaceId: workspace.repoDir, workspaceId: workspace.repoDir,
projectSlug: "repo", projectSlug: "repo",
@@ -288,7 +288,7 @@ describe("script-route-branch-handler", () => {
const onRoutesChanged = vi.fn(); const onRoutesChanged = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, serviceProxy: routeStore,
onRoutesChanged, onRoutesChanged,
}); });
@@ -297,7 +297,7 @@ describe("script-route-branch-handler", () => {
expect(routeStore.listRoutesForWorkspace(workspace.repoDir)).toEqual([ expect(routeStore.listRoutesForWorkspace(workspace.repoDir)).toEqual([
{ {
hostname: "api.feature-billing.repo.localhost", hostname: "api--feature-billing--repo.localhost",
port: 3001, port: 3001,
workspaceId: workspace.repoDir, workspaceId: workspace.repoDir,
projectSlug: "repo", projectSlug: "repo",
@@ -309,4 +309,99 @@ describe("script-route-branch-handler", () => {
workspace.cleanup(); 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 type { Logger } from "pino";
import { buildPublicScriptHostname, buildScriptHostname } from "../utils/script-hostname.js"; import type { ServiceProxySubsystem } from "./service-proxy.js";
import type { ScriptRouteEntry, ScriptRouteStore } from "./script-proxy.js";
interface BranchChangeRouteHandlerOptions { interface BranchChangeRouteHandlerOptions {
routeStore: ScriptRouteStore; serviceProxy: ServiceProxySubsystem;
onRoutesChanged: (workspaceId: string) => void; onRoutesChanged: (workspaceId: string) => void;
logger?: Logger; logger?: Logger;
} }
interface RouteHostnameUpdate {
oldHostname: string;
newHostname: string;
newPublicHostname: string | null;
route: ScriptRouteEntry;
}
export function createBranchChangeRouteHandler( export function createBranchChangeRouteHandler(
options: BranchChangeRouteHandlerOptions, options: BranchChangeRouteHandlerOptions,
): (workspaceId: string, oldBranch: string | null, newBranch: string | null) => void { ): (workspaceId: string, oldBranch: string | null, newBranch: string | null) => void {
return (workspaceId, _oldBranch, newBranch) => { return (workspaceId, _oldBranch, newBranch) => {
// Only service scripts register routes, so branch renames only touch services. const changed = options.serviceProxy.replaceWorkspaceBranchRoutes({ workspaceId, newBranch });
const routes = options.routeStore.listRoutesForWorkspace(workspaceId); if (!changed) {
if (routes.length === 0) {
return; return;
} }
options.logger?.info(
const updates: RouteHostnameUpdate[] = []; { workspaceId, newBranch },
for (const route of routes) { "Updated service proxy routes for branch rename",
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",
);
}
options.onRoutesChanged(workspaceId); options.onRoutesChanged(workspaceId);
}; };
} }
@@ -54,15 +54,22 @@ function buildPayloads(input: {
workspaceId: string; workspaceId: string;
workspaceDirectory: string; workspaceDirectory: string;
paseoConfig?: PaseoConfig | null; paseoConfig?: PaseoConfig | null;
routeStore: ScriptRouteStore; routeStore?: ScriptRouteStore;
serviceProxy?: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore; runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null; daemonPort: number | null;
serviceProxyPublicBaseUrl?: string | null;
gitMetadata?: { projectSlug: string; currentBranch: string | null }; gitMetadata?: { projectSlug: string; currentBranch: string | null };
resolveHealth?: (hostname: string) => ScriptHealthState | null; resolveHealth?: (hostname: string) => ScriptHealthState | null;
}) { }) {
const paseoConfig = const paseoConfig =
input.paseoConfig !== undefined ? input.paseoConfig : loadConfig(input.workspaceDirectory); 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 { function loadConfig(repoRoot: string): PaseoConfig | null {
@@ -131,9 +138,11 @@ describe("script-status-projection", () => {
{ {
scriptName: "web", scriptName: "web",
type: "service", type: "service",
hostname: "web.repo.localhost", hostname: "web--repo.localhost",
port: 3000, port: 3000,
proxyUrl: "http://web.repo.localhost:6767", localProxyUrl: "http://web--repo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://web--repo.localhost:6767",
lifecycle: "stopped", lifecycle: "stopped",
health: null, health: null,
exitCode: null, exitCode: null,
@@ -162,7 +171,7 @@ describe("script-status-projection", () => {
const payloads = buildPayloads({ const payloads = buildPayloads({
workspaceId, workspaceId,
workspaceDirectory: workspace.repoDir, workspaceDirectory: workspace.repoDir,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
daemonPort: 6767, daemonPort: 6767,
gitMetadata: { gitMetadata: {
@@ -175,9 +184,54 @@ describe("script-status-projection", () => {
{ {
scriptName: "web", scriptName: "web",
type: "service", type: "service",
hostname: "web.feature-from-service.service-provided.localhost", hostname: "web--feature-from-service--service-provided.localhost",
port: 3000, 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", lifecycle: "stopped",
health: null, health: null,
exitCode: null, exitCode: null,
@@ -201,7 +255,7 @@ describe("script-status-projection", () => {
}); });
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "web.feature-card.repo.localhost", hostname: "web--feature-card--repo.localhost",
port: 4321, port: 4321,
workspaceId, workspaceId,
projectSlug: "repo", projectSlug: "repo",
@@ -231,9 +285,11 @@ describe("script-status-projection", () => {
{ {
scriptName: "web", scriptName: "web",
type: "service", type: "service",
hostname: "web.feature-card.repo.localhost", hostname: "web--feature-card--repo.localhost",
port: 4321, 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", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null, exitCode: null,
@@ -256,7 +312,7 @@ describe("script-status-projection", () => {
}); });
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "web.repo.localhost", hostname: "web--repo.localhost",
port: 4321, port: 4321,
workspaceId, workspaceId,
projectSlug: "repo", projectSlug: "repo",
@@ -286,9 +342,11 @@ describe("script-status-projection", () => {
{ {
scriptName: "web", scriptName: "web",
type: "service", type: "service",
hostname: "web.repo.localhost", hostname: "web--repo.localhost",
port: 4321, port: 4321,
proxyUrl: "http://web.repo.localhost:6767", localProxyUrl: "http://web--repo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://web--repo.localhost:6767",
lifecycle: "running", lifecycle: "running",
health: null, health: null,
exitCode: null, exitCode: null,
@@ -305,7 +363,7 @@ describe("script-status-projection", () => {
const workspace = createWorkspaceRepo(); const workspace = createWorkspaceRepo();
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "docs.repo.localhost", hostname: "docs--repo.localhost",
port: 3002, port: 3002,
workspaceId, workspaceId,
projectSlug: "repo", projectSlug: "repo",
@@ -334,9 +392,11 @@ describe("script-status-projection", () => {
{ {
scriptName: "docs", scriptName: "docs",
type: "service", type: "service",
hostname: "docs.repo.localhost", hostname: "docs--repo.localhost",
port: 3002, port: 3002,
proxyUrl: "http://docs.repo.localhost:6767", localProxyUrl: "http://docs--repo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://docs--repo.localhost:6767",
lifecycle: "running", lifecycle: "running",
health: null, health: null,
exitCode: null, exitCode: null,
@@ -462,7 +522,7 @@ describe("script-status-projection", () => {
}); });
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
routeStore.registerRoute({ routeStore.registerRoute({
hostname: "api.repo.localhost", hostname: "api--repo.localhost",
port: 3001, port: 3001,
workspaceId, workspaceId,
projectSlug: "repo", projectSlug: "repo",
@@ -481,7 +541,7 @@ describe("script-status-projection", () => {
const session = { emit: vi.fn() }; const session = { emit: vi.fn() };
const emitUpdate = createScriptStatusEmitter({ const emitUpdate = createScriptStatusEmitter({
sessions: () => [session], sessions: () => [session],
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
daemonPort: 6767, daemonPort: 6767,
resolveWorkspaceDirectory: async (requestedWorkspaceId) => resolveWorkspaceDirectory: async (requestedWorkspaceId) =>
@@ -493,7 +553,7 @@ describe("script-status-projection", () => {
emitUpdate(workspaceId, [ emitUpdate(workspaceId, [
{ {
scriptName: "api", scriptName: "api",
hostname: "api.repo.localhost", hostname: "api--repo.localhost",
port: 3001, port: 3001,
health: "healthy", health: "healthy",
}, },
@@ -508,9 +568,11 @@ describe("script-status-projection", () => {
{ {
scriptName: "api", scriptName: "api",
type: "service", type: "service",
hostname: "api.repo.localhost", hostname: "api--repo.localhost",
port: 3001, port: 3001,
proxyUrl: "http://api.repo.localhost:6767", localProxyUrl: "http://api--repo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://api--repo.localhost:6767",
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null, exitCode: null,
@@ -5,11 +5,13 @@ import type {
WorkspaceScriptPayload, WorkspaceScriptPayload,
} from "@getpaseo/protocol/messages"; } from "@getpaseo/protocol/messages";
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema"; 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 { getScriptConfigs, isServiceScript, readPaseoConfig } from "../utils/worktree.js";
import { deriveProjectSlug } from "./workspace-git-metadata.js"; import { deriveProjectSlug } from "./workspace-git-metadata.js";
import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.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"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
interface SessionEmitter { interface SessionEmitter {
@@ -20,7 +22,7 @@ interface BuildWorkspaceScriptPayloadsOptions {
workspaceId: string; workspaceId: string;
workspaceDirectory: string; workspaceDirectory: string;
paseoConfig: PaseoConfig | null; paseoConfig: PaseoConfig | null;
routeStore: ScriptRouteStore; serviceProxy: ServiceProxySubsystem;
runtimeStore: WorkspaceScriptRuntimeStore; runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null; daemonPort: number | null;
serviceProxyPublicBaseUrl?: string | null; serviceProxyPublicBaseUrl?: string | null;
@@ -53,28 +55,6 @@ function resolveDaemonPort(daemonPort: number | null | (() => number | null)): n
return daemonPort; 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"] { function toWireHealth(health: ScriptHealthState | null): WorkspaceScriptPayload["health"] {
if (health === "pending" || health === null) { if (health === "pending" || health === null) {
return null; return null;
@@ -92,54 +72,92 @@ function sortPayloads(payloads: WorkspaceScriptPayload[]): WorkspaceScriptPayloa
} }
type RuntimeEntry = ReturnType<WorkspaceScriptRuntimeStore["listForWorkspace"]>[number]; type RuntimeEntry = ReturnType<WorkspaceScriptRuntimeStore["listForWorkspace"]>[number];
type RouteEntry = ReturnType<ScriptRouteStore["listRoutesForWorkspace"]>[number];
interface BuildPayloadContext { interface BuildPayloadContext {
projectSlug: string; projectSlug: string;
branchName: string | null; branchName: string | null;
daemonPort: number | null; daemonPort: number | null;
serviceProxyPublicBaseUrl?: string | null; serviceProxyPublicBaseUrl?: string | null;
serviceProxy: ServiceProxySubsystem;
resolveHealth?: (hostname: string) => ScriptHealthState | null; 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( function buildConfiguredScriptPayload(
scriptName: string, scriptName: string,
config: ReturnType<typeof getScriptConfigs> extends Map<string, infer V> ? V : never, config: ReturnType<typeof getScriptConfigs> extends Map<string, infer V> ? V : never,
runtimeEntry: RuntimeEntry | null, runtimeEntry: RuntimeEntry | null,
routeEntry: RouteEntry | null, serviceState: ServiceProxyWorkspaceScriptProjection | null,
ctx: BuildPayloadContext, ctx: BuildPayloadContext,
): WorkspaceScriptPayload { ): WorkspaceScriptPayload {
const configIsService = isServiceScript(config); const configIsService = isServiceScript(config);
const type = configIsService ? "service" : "script"; if (!configIsService) {
const configuredPort = configIsService ? (config.port ?? null) : null; return buildConfiguredPlainScriptPayload(scriptName, runtimeEntry);
const hostname = }
type === "service"
? (routeEntry?.hostname ?? const type = "service";
buildScriptHostname({ const configuredPort = config.port ?? null;
projectSlug: ctx.projectSlug, const hostname = (
branchName: ctx.branchName, serviceState ??
scriptName, ctx.serviceProxy.projectWorkspaceService({
})) projectSlug: ctx.projectSlug,
: scriptName; branchName: ctx.branchName,
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 { return {
scriptName, scriptName,
type, type,
hostname, hostname,
port: type === "service" ? (routeEntry?.port ?? configuredPort) : null, port: serviceState?.port ?? configuredPort,
proxyUrl: localProxyUrl: urls.localProxyUrl,
type === "service" publicProxyUrl: urls.publicProxyUrl,
? toServiceProxyUrl({ proxyUrl: urls.proxyUrl,
hostname,
daemonPort: ctx.daemonPort,
serviceProxyPublicBaseUrl: ctx.serviceProxyPublicBaseUrl,
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName,
})
: null,
lifecycle: runtimeEntry?.lifecycle ?? "stopped", lifecycle: runtimeEntry?.lifecycle ?? "stopped",
health: type === "service" ? toWireHealth(ctx.resolveHealth?.(hostname) ?? null) : null, health: toWireHealth(ctx.resolveHealth?.(hostname) ?? null),
exitCode: runtimeEntry?.exitCode ?? null, exitCode: runtimeEntry?.exitCode ?? null,
terminalId: runtimeEntry?.terminalId ?? null, terminalId: runtimeEntry?.terminalId ?? null,
}; };
@@ -147,38 +165,47 @@ function buildConfiguredScriptPayload(
function buildOrphanRuntimePayload( function buildOrphanRuntimePayload(
runtimeEntry: RuntimeEntry, runtimeEntry: RuntimeEntry,
routeEntry: RouteEntry | null, serviceState: ServiceProxyWorkspaceScriptProjection | null,
ctx: BuildPayloadContext, ctx: BuildPayloadContext,
): WorkspaceScriptPayload { ): WorkspaceScriptPayload {
const type = runtimeEntry.type; const type = runtimeEntry.type;
const hostname = const hostname =
type === "service" type === "service"
? (routeEntry?.hostname ?? ? (
buildScriptHostname({ serviceState ??
projectSlug: ctx.projectSlug, ctx.serviceProxy.projectWorkspaceService({
branchName: ctx.branchName, projectSlug: ctx.projectSlug,
scriptName: runtimeEntry.scriptName, branchName: ctx.branchName,
})) scriptName: runtimeEntry.scriptName,
daemonPort: ctx.daemonPort,
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
})
).hostname
: runtimeEntry.scriptName; : runtimeEntry.scriptName;
const urls =
serviceState ??
ctx.serviceProxy.projectUrls({
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName: runtimeEntry.scriptName,
daemonPort: ctx.daemonPort,
publicBaseUrl: ctx.serviceProxyPublicBaseUrl,
});
return { return {
scriptName: runtimeEntry.scriptName, scriptName: runtimeEntry.scriptName,
type, type,
hostname, hostname,
port: type === "service" ? (routeEntry?.port ?? null) : null, port: type === "service" ? (serviceState?.port ?? null) : null,
proxyUrl: ...(type === "service"
type === "service" ? { localProxyUrl: urls.localProxyUrl, publicProxyUrl: urls.publicProxyUrl }
? toServiceProxyUrl({ : {}),
hostname, proxyUrl: type === "service" ? urls.proxyUrl : null,
daemonPort: ctx.daemonPort,
serviceProxyPublicBaseUrl: ctx.serviceProxyPublicBaseUrl,
projectSlug: ctx.projectSlug,
branchName: ctx.branchName,
scriptName: runtimeEntry.scriptName,
})
: null,
lifecycle: runtimeEntry.lifecycle, lifecycle: runtimeEntry.lifecycle,
health: health:
type === "service" && routeEntry ? toWireHealth(ctx.resolveHealth?.(hostname) ?? null) : null, type === "service" && serviceState?.port !== null
? toWireHealth(ctx.resolveHealth?.(hostname) ?? null)
: null,
exitCode: runtimeEntry.exitCode, exitCode: runtimeEntry.exitCode,
terminalId: runtimeEntry.terminalId, terminalId: runtimeEntry.terminalId,
}; };
@@ -197,17 +224,12 @@ export function buildWorkspaceScriptPayloads(
.listForWorkspace(workspaceId) .listForWorkspace(workspaceId)
.map((entry) => [entry.scriptName, entry] as const), .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 = { const ctx: BuildPayloadContext = {
projectSlug, projectSlug,
branchName, branchName,
daemonPort: options.daemonPort, daemonPort: options.daemonPort,
serviceProxyPublicBaseUrl: options.serviceProxyPublicBaseUrl, serviceProxyPublicBaseUrl: options.serviceProxyPublicBaseUrl,
serviceProxy: options.serviceProxy,
resolveHealth: options.resolveHealth, resolveHealth: options.resolveHealth,
}; };
@@ -215,16 +237,23 @@ export function buildWorkspaceScriptPayloads(
for (const [scriptName, config] of scriptConfigs.entries()) { for (const [scriptName, config] of scriptConfigs.entries()) {
const runtimeEntry = runtimeEntries.get(scriptName) ?? null; const runtimeEntry = runtimeEntries.get(scriptName) ?? null;
const routeEntry = routesByScriptName.get(scriptName) ?? null; const serviceState = isServiceScript(config)
payloads.push(buildConfiguredScriptPayload(scriptName, config, runtimeEntry, routeEntry, ctx)); ? projectWorkspaceServiceState({ workspaceId, scriptName, ctx })
: null;
payloads.push(
buildConfiguredScriptPayload(scriptName, config, runtimeEntry, serviceState, ctx),
);
} }
for (const runtimeEntry of runtimeEntries.values()) { for (const runtimeEntry of runtimeEntries.values()) {
if (scriptConfigs.has(runtimeEntry.scriptName) || runtimeEntry.lifecycle !== "running") { if (scriptConfigs.has(runtimeEntry.scriptName) || runtimeEntry.lifecycle !== "running") {
continue; continue;
} }
const routeEntry = routesByScriptName.get(runtimeEntry.scriptName) ?? null; const serviceState =
payloads.push(buildOrphanRuntimePayload(runtimeEntry, routeEntry, ctx)); runtimeEntry.type === "service"
? projectWorkspaceServiceState({ workspaceId, scriptName: runtimeEntry.scriptName, ctx })
: null;
payloads.push(buildOrphanRuntimePayload(runtimeEntry, serviceState, ctx));
} }
return sortPayloads(payloads); return sortPayloads(payloads);
@@ -245,7 +274,7 @@ function buildScriptStatusUpdateMessage(params: {
export function createScriptStatusEmitter({ export function createScriptStatusEmitter({
sessions, sessions,
routeStore, serviceProxy,
runtimeStore, runtimeStore,
daemonPort, daemonPort,
serviceProxyPublicBaseUrl, serviceProxyPublicBaseUrl,
@@ -253,7 +282,7 @@ export function createScriptStatusEmitter({
logger, logger,
}: { }: {
sessions: () => SessionEmitter[]; sessions: () => SessionEmitter[];
routeStore: ScriptRouteStore; serviceProxy: ServiceProxySubsystem;
runtimeStore: WorkspaceScriptRuntimeStore; runtimeStore: WorkspaceScriptRuntimeStore;
daemonPort: number | null | (() => number | null); daemonPort: number | null | (() => number | null);
serviceProxyPublicBaseUrl?: string | null; serviceProxyPublicBaseUrl?: string | null;
@@ -276,7 +305,7 @@ export function createScriptStatusEmitter({
workspaceId, workspaceId,
workspaceDirectory, workspaceDirectory,
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger), paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger),
routeStore, serviceProxy,
runtimeStore, runtimeStore,
daemonPort: resolvedDaemonPort, daemonPort: resolvedDaemonPort,
serviceProxyPublicBaseUrl, 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> }; workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
projectRegistry?: Partial<SessionOptions["projectRegistry"]>; projectRegistry?: Partial<SessionOptions["projectRegistry"]>;
terminalManager?: SessionOptions["terminalManager"]; terminalManager?: SessionOptions["terminalManager"];
scriptRouteStore?: SessionOptions["scriptRouteStore"]; serviceProxy?: SessionOptions["serviceProxy"];
scriptRuntimeStore?: SessionOptions["scriptRuntimeStore"]; scriptRuntimeStore?: SessionOptions["scriptRuntimeStore"];
getDaemonTcpPort?: () => number | null; getDaemonTcpPort?: () => number | null;
getDaemonTcpHost?: () => string | null; getDaemonTcpHost?: () => string | null;
@@ -311,7 +311,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
terminalManager: options.terminalManager ?? null, terminalManager: options.terminalManager ?? null,
providerSnapshotManager: providerSnapshotManager:
options.providerSnapshotManager ?? createProviderSnapshotManagerStub().manager, options.providerSnapshotManager ?? createProviderSnapshotManagerStub().manager,
scriptRouteStore: options.scriptRouteStore, serviceProxy: options.serviceProxy,
scriptRuntimeStore: options.scriptRuntimeStore, scriptRuntimeStore: options.scriptRuntimeStore,
getDaemonTcpPort: options.getDaemonTcpPort, getDaemonTcpPort: options.getDaemonTcpPort,
getDaemonTcpHost: options.getDaemonTcpHost, getDaemonTcpHost: options.getDaemonTcpHost,
@@ -3549,7 +3549,7 @@ describe("session workspace script handling", () => {
workspaceGitService, workspaceGitService,
workspaceRegistry, workspaceRegistry,
terminalManager: { subscribeTerminalsChanged: vi.fn(() => () => {}) }, terminalManager: { subscribeTerminalsChanged: vi.fn(() => () => {}) },
scriptRouteStore: { listRoutesForWorkspace: vi.fn(() => []) }, serviceProxy: { listRoutesForWorkspace: vi.fn(() => []) },
scriptRuntimeStore: { listForWorkspace: vi.fn(() => []) }, scriptRuntimeStore: { listForWorkspace: vi.fn(() => []) },
getDaemonTcpPort: () => 6767, getDaemonTcpPort: () => 6767,
getDaemonTcpHost: () => "127.0.0.1", getDaemonTcpHost: () => "127.0.0.1",
+12 -12
View File
@@ -192,7 +192,7 @@ import {
import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js"; import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js";
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js"; import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import type { ScriptRouteStore } from "./script-proxy.js"; import type { ServiceProxySubsystem } from "./service-proxy.js";
import { import {
checkoutResolvedBranch, checkoutResolvedBranch,
type CheckoutExistingBranchResult, type CheckoutExistingBranchResult,
@@ -588,7 +588,7 @@ export interface SessionOptions {
tts: Resolvable<TextToSpeechProvider | null>; tts: Resolvable<TextToSpeechProvider | null>;
terminalManager: TerminalManager | null; terminalManager: TerminalManager | null;
providerSnapshotManager: ProviderSnapshotManager; providerSnapshotManager: ProviderSnapshotManager;
scriptRouteStore?: ScriptRouteStore; serviceProxy?: ServiceProxySubsystem;
scriptRuntimeStore?: WorkspaceScriptRuntimeStore; scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
workspaceSetupSnapshots?: Map<string, WorkspaceSetupSnapshot>; workspaceSetupSnapshots?: Map<string, WorkspaceSetupSnapshot>;
onBranchChanged?: ( onBranchChanged?: (
@@ -803,7 +803,7 @@ export class Session {
private readonly terminalManager: TerminalManager | null; private readonly terminalManager: TerminalManager | null;
private readonly providerSnapshotManager: ProviderSnapshotManager; private readonly providerSnapshotManager: ProviderSnapshotManager;
private unsubscribeProviderSnapshotEvents: (() => void) | null = null; private unsubscribeProviderSnapshotEvents: (() => void) | null = null;
private readonly scriptRouteStore: ScriptRouteStore | null; private readonly serviceProxy: ServiceProxySubsystem | null;
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
private readonly onBranchChanged?: ( private readonly onBranchChanged?: (
workspaceId: string, workspaceId: string,
@@ -881,7 +881,7 @@ export class Session {
tts, tts,
terminalManager, terminalManager,
providerSnapshotManager, providerSnapshotManager,
scriptRouteStore, serviceProxy,
scriptRuntimeStore, scriptRuntimeStore,
workspaceSetupSnapshots, workspaceSetupSnapshots,
onBranchChanged, onBranchChanged,
@@ -966,7 +966,7 @@ export class Session {
logger: this.sessionLogger, logger: this.sessionLogger,
}); });
this.providerSnapshotManager = providerSnapshotManager; this.providerSnapshotManager = providerSnapshotManager;
this.scriptRouteStore = scriptRouteStore ?? null; this.serviceProxy = serviceProxy ?? null;
this.scriptRuntimeStore = scriptRuntimeStore ?? null; this.scriptRuntimeStore = scriptRuntimeStore ?? null;
this.workspaceSetupSnapshots = workspaceSetupSnapshots ?? new Map(); this.workspaceSetupSnapshots = workspaceSetupSnapshots ?? new Map();
this.onBranchChanged = onBranchChanged; this.onBranchChanged = onBranchChanged;
@@ -6295,12 +6295,12 @@ export class Session {
activityAt: null, activityAt: null,
diffStat, diffStat,
scripts: scripts:
this.scriptRouteStore && this.scriptRuntimeStore this.serviceProxy && this.scriptRuntimeStore
? buildWorkspaceScriptPayloads({ ? buildWorkspaceScriptPayloads({
workspaceId: workspace.workspaceId, workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.cwd, workspaceDirectory: workspace.cwd,
paseoConfig: readPaseoConfigForProjection(workspace.cwd, this.sessionLogger), paseoConfig: readPaseoConfigForProjection(workspace.cwd, this.sessionLogger),
routeStore: this.scriptRouteStore, serviceProxy: this.serviceProxy,
runtimeStore: this.scriptRuntimeStore, runtimeStore: this.scriptRuntimeStore,
daemonPort: this.getDaemonTcpPort?.() ?? null, daemonPort: this.getDaemonTcpPort?.() ?? null,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl, serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
@@ -7124,14 +7124,14 @@ export class Session {
workspaceId: string, workspaceId: string,
workspaceDirectory: string, workspaceDirectory: string,
): WorkspaceDescriptorPayload["scripts"] { ): WorkspaceDescriptorPayload["scripts"] {
if (!this.scriptRouteStore || !this.scriptRuntimeStore) { if (!this.serviceProxy || !this.scriptRuntimeStore) {
return []; return [];
} }
return buildWorkspaceScriptPayloads({ return buildWorkspaceScriptPayloads({
workspaceId, workspaceId,
workspaceDirectory, workspaceDirectory,
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, this.sessionLogger), paseoConfig: readPaseoConfigForProjection(workspaceDirectory, this.sessionLogger),
routeStore: this.scriptRouteStore, serviceProxy: this.serviceProxy,
runtimeStore: this.scriptRuntimeStore, runtimeStore: this.scriptRuntimeStore,
daemonPort: this.getDaemonTcpPort?.() ?? null, daemonPort: this.getDaemonTcpPort?.() ?? null,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl, serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
@@ -7182,7 +7182,7 @@ export class Session {
request: StartWorkspaceScriptRequest, request: StartWorkspaceScriptRequest,
): Promise<void> { ): Promise<void> {
try { 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"); throw new Error("Workspace scripts are not available on this daemon");
} }
@@ -7201,7 +7201,7 @@ export class Session {
daemonPort: this.getDaemonTcpPort?.() ?? null, daemonPort: this.getDaemonTcpPort?.() ?? null,
daemonListenHost: this.getDaemonTcpHost?.() ?? null, daemonListenHost: this.getDaemonTcpHost?.() ?? null,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl, serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
routeStore: this.scriptRouteStore, serviceProxy: this.serviceProxy,
runtimeStore: this.scriptRuntimeStore, runtimeStore: this.scriptRuntimeStore,
terminalManager: this.terminalManager, terminalManager: this.terminalManager,
logger: this.sessionLogger, logger: this.sessionLogger,
@@ -7348,7 +7348,7 @@ export class Session {
sessionLogger: this.sessionLogger, sessionLogger: this.sessionLogger,
terminalManager: this.terminalManager, terminalManager: this.terminalManager,
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
scriptRouteStore: this.scriptRouteStore, serviceProxy: this.serviceProxy,
scriptRuntimeStore: this.scriptRuntimeStore, scriptRuntimeStore: this.scriptRuntimeStore,
getDaemonTcpPort: this.getDaemonTcpPort, getDaemonTcpPort: this.getDaemonTcpPort,
getDaemonTcpHost: this.getDaemonTcpHost, getDaemonTcpHost: this.getDaemonTcpHost,
@@ -2,10 +2,11 @@ import { describe, expect, test, vi } from "vitest";
import path from "node:path"; import path from "node:path";
import type pino from "pino"; import type pino from "pino";
import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js"; 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 { Session, type SessionOptions } from "./session.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js";
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.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 { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { import type {
WorkspaceGitListener, WorkspaceGitListener,
@@ -102,7 +103,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
oldBranch: string | null, oldBranch: string | null,
newBranch: string | null, newBranch: string | null,
) => void; ) => void;
scriptRouteStore?: ScriptRouteStore; serviceProxy?: ServiceProxySubsystem;
scriptRuntimeStore?: WorkspaceScriptRuntimeStore; scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
}): { }): {
session: Session; session: Session;
@@ -234,7 +235,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
tts: null, tts: null,
providerSnapshotManager: createProviderSnapshotManagerStub().manager, providerSnapshotManager: createProviderSnapshotManagerStub().manager,
terminalManager: null, terminalManager: null,
scriptRouteStore: options?.scriptRouteStore, serviceProxy: options?.serviceProxy,
scriptRuntimeStore: options?.scriptRuntimeStore, scriptRuntimeStore: options?.scriptRuntimeStore,
onBranchChanged: options?.onBranchChanged, onBranchChanged: options?.onBranchChanged,
getDaemonTcpPort: () => 6767, getDaemonTcpPort: () => 6767,
@@ -425,12 +426,12 @@ describe("workspace git watch targets", () => {
}); });
test("updates running service script URLs when the git branch changes", async () => { test("updates running service script URLs when the git branch changes", async () => {
const routeStore = new ScriptRouteStore(); const serviceProxy = createServiceProxySubsystem({ logger: createTestLogger() });
routeStore.registerRoute({ serviceProxy.registerWorkspaceService({
hostname: "app.old-branch.paseo.localhost",
port: 4321, port: 4321,
workspaceId: "ws-10", workspaceId: "ws-10",
projectSlug: "paseo", projectSlug: "paseo",
branchName: "old-branch",
scriptName: "app", scriptName: "app",
}); });
const runtimeStore = new WorkspaceScriptRuntimeStore(); const runtimeStore = new WorkspaceScriptRuntimeStore();
@@ -444,12 +445,12 @@ describe("workspace git watch targets", () => {
}); });
const handleBranchChange = createBranchChangeRouteHandler({ const handleBranchChange = createBranchChangeRouteHandler({
routeStore, serviceProxy,
onRoutesChanged: vi.fn(), onRoutesChanged: vi.fn(),
}); });
const { session, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests( const { session, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests(
{ {
scriptRouteStore: routeStore, serviceProxy,
scriptRuntimeStore: runtimeStore, scriptRuntimeStore: runtimeStore,
onBranchChanged: handleBranchChange, 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({ expect.objectContaining({
hostname: "app.new-branch.paseo.localhost", hostname: "app--new-branch--paseo.localhost",
projectSlug: "paseo",
scriptName: "app", scriptName: "app",
}), }),
]); ]);
expect(sessionAny.buildWorkspaceScriptPayloadSnapshot("ws-10", "/tmp/repo")).toEqual([ expect(sessionAny.buildWorkspaceScriptPayloadSnapshot("ws-10", "/tmp/repo")).toEqual([
expect.objectContaining({ expect.objectContaining({
scriptName: "app", scriptName: "app",
hostname: "app.new-branch.paseo.localhost", hostname: "app--new-branch--paseo.localhost",
proxyUrl: "http://app.new-branch.paseo.localhost:6767", 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; dictationFinalTimeoutMs?: number;
auth?: PaseoDaemonConfig["auth"]; auth?: PaseoDaemonConfig["auth"];
pushNotificationSender?: PushNotificationSender; pushNotificationSender?: PushNotificationSender;
serviceProxy?: PaseoDaemonConfig["serviceProxy"];
} }
export interface TestPaseoDaemon { export interface TestPaseoDaemon {
@@ -161,6 +162,7 @@ async function prepareTestDaemonConfig(
appBaseUrl: "https://app.paseo.sh", appBaseUrl: "https://app.paseo.sh",
auth: options.auth, auth: options.auth,
pushNotificationSender: options.pushNotificationSender, pushNotificationSender: options.pushNotificationSender,
serviceProxy: options.serviceProxy,
openai: options.openai, openai: options.openai,
speech: options.speech, speech: options.speech,
voiceLlmProvider: options.voiceLlmProvider ?? null, voiceLlmProvider: options.voiceLlmProvider ?? null,
@@ -92,10 +92,10 @@ export function asWorkspaceGitService(stub: {
return createStub<SessionOptions["workspaceGitService"]>(stub); return createStub<SessionOptions["workspaceGitService"]>(stub);
} }
export function asScriptRouteStore(stub: { export function asServiceProxy(stub: {
[K in keyof SessionOptions["scriptRouteStore"]]?: unknown; [K in keyof SessionOptions["serviceProxy"]]?: unknown;
}): SessionOptions["scriptRouteStore"] { }): SessionOptions["serviceProxy"] {
return createStub<SessionOptions["scriptRouteStore"]>(stub); return createStub<SessionOptions["serviceProxy"]>(stub);
} }
export function asWorkspaceScriptRuntimeStore(stub: { export function asWorkspaceScriptRuntimeStore(stub: {
@@ -35,7 +35,7 @@ import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.
import { PushTokenStore } from "./push/token-store.js"; import { PushTokenStore } from "./push/token-store.js";
import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js"; import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js";
import type { ScriptHealthState } from "./script-health-monitor.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 { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js"; import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
@@ -357,7 +357,7 @@ export class VoiceAssistantWebSocketServer {
private readonly mcpBaseUrl: string | null; private readonly mcpBaseUrl: string | null;
private speech!: SpeechService | null; private speech!: SpeechService | null;
private terminalManager!: TerminalManager | null; private terminalManager!: TerminalManager | null;
private scriptRouteStore!: ScriptRouteStore | null; private serviceProxy!: ServiceProxySubsystem | null;
private scriptRuntimeStore!: WorkspaceScriptRuntimeStore | null; private scriptRuntimeStore!: WorkspaceScriptRuntimeStore | null;
private getDaemonTcpPort!: (() => number | null) | null; private getDaemonTcpPort!: (() => number | null) | null;
private getDaemonTcpHost!: (() => string | null) | null; private getDaemonTcpHost!: (() => string | null) | null;
@@ -405,7 +405,7 @@ export class VoiceAssistantWebSocketServer {
loopService?: LoopService, loopService?: LoopService,
scheduleService?: ScheduleService, scheduleService?: ScheduleService,
checkoutDiffManager?: CheckoutDiffManager, checkoutDiffManager?: CheckoutDiffManager,
scriptRouteStore?: ScriptRouteStore | null, serviceProxy?: ServiceProxySubsystem | null,
scriptRuntimeStore?: WorkspaceScriptRuntimeStore | null, scriptRuntimeStore?: WorkspaceScriptRuntimeStore | null,
onBranchChanged?: ( onBranchChanged?: (
workspaceId: string, workspaceId: string,
@@ -465,7 +465,7 @@ export class VoiceAssistantWebSocketServer {
terminalManager, terminalManager,
dictation, dictation,
onLifecycleIntent, onLifecycleIntent,
scriptRouteStore, serviceProxy,
scriptRuntimeStore, scriptRuntimeStore,
onBranchChanged, onBranchChanged,
getDaemonTcpPort, getDaemonTcpPort,
@@ -514,7 +514,7 @@ export class VoiceAssistantWebSocketServer {
terminalManager: TerminalManager | null | undefined; terminalManager: TerminalManager | null | undefined;
dictation: { finalTimeoutMs?: number } | undefined; dictation: { finalTimeoutMs?: number } | undefined;
onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | undefined; onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | undefined;
scriptRouteStore: ScriptRouteStore | null | undefined; serviceProxy: ServiceProxySubsystem | null | undefined;
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null | undefined; scriptRuntimeStore: WorkspaceScriptRuntimeStore | null | undefined;
onBranchChanged: onBranchChanged:
| ((workspaceId: string, oldBranch: string | null, newBranch: string | null) => void) | ((workspaceId: string, oldBranch: string | null, newBranch: string | null) => void)
@@ -528,7 +528,7 @@ export class VoiceAssistantWebSocketServer {
this.terminalManager = params.terminalManager ?? null; this.terminalManager = params.terminalManager ?? null;
this.dictation = params.dictation ?? null; this.dictation = params.dictation ?? null;
this.onLifecycleIntent = params.onLifecycleIntent ?? null; this.onLifecycleIntent = params.onLifecycleIntent ?? null;
this.scriptRouteStore = params.scriptRouteStore ?? null; this.serviceProxy = params.serviceProxy ?? null;
this.scriptRuntimeStore = params.scriptRuntimeStore ?? null; this.scriptRuntimeStore = params.scriptRuntimeStore ?? null;
this.onBranchChanged = params.onBranchChanged ?? null; this.onBranchChanged = params.onBranchChanged ?? null;
this.getDaemonTcpPort = params.getDaemonTcpPort ?? null; this.getDaemonTcpPort = params.getDaemonTcpPort ?? null;
@@ -884,7 +884,7 @@ export class VoiceAssistantWebSocketServer {
tts: () => this.speech?.resolveTts() ?? null, tts: () => this.speech?.resolveTts() ?? null,
terminalManager: this.terminalManager, terminalManager: this.terminalManager,
providerSnapshotManager: this.providerSnapshotManager, providerSnapshotManager: this.providerSnapshotManager,
scriptRouteStore: this.scriptRouteStore ?? undefined, serviceProxy: this.serviceProxy ?? undefined,
scriptRuntimeStore: this.scriptRuntimeStore ?? undefined, scriptRuntimeStore: this.scriptRuntimeStore ?? undefined,
workspaceSetupSnapshots: this.workspaceSetupSnapshots, workspaceSetupSnapshots: this.workspaceSetupSnapshots,
onBranchChanged: this.onBranchChanged ?? undefined, onBranchChanged: this.onBranchChanged ?? undefined,
@@ -65,9 +65,9 @@ describe("buildWorkspaceServiceEnv", () => {
).toEqual({ ).toEqual({
HOST: "127.0.0.1", HOST: "127.0.0.1",
PASEO_PORT: "5173", 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_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({ ).toEqual({
HOST: "127.0.0.1", HOST: "127.0.0.1",
PASEO_PORT: "5173", 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_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({ ).toEqual({
HOST: "127.0.0.1", HOST: "127.0.0.1",
PASEO_PORT: "5173", 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_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_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 { export interface WorkspaceServicePeer {
scriptName: string; scriptName: string;
@@ -81,25 +81,13 @@ interface BuildServiceProxyUrlOptions {
} }
function buildServiceProxyUrl(options: BuildServiceProxyUrlOptions): string | null { function buildServiceProxyUrl(options: BuildServiceProxyUrlOptions): string | null {
if (options.serviceProxyPublicBaseUrl) { return projectServiceProxyUrls({
return buildPublicScriptProxyUrl({
projectSlug: options.projectSlug,
branchName: options.branchName,
scriptName: options.scriptName,
publicBaseUrl: options.serviceProxyPublicBaseUrl,
});
}
if (options.daemonPort === null || options.daemonPort === undefined) {
return null;
}
const hostname = buildScriptHostname({
projectSlug: options.projectSlug, projectSlug: options.projectSlug,
branchName: options.branchName, branchName: options.branchName,
scriptName: options.scriptName, scriptName: options.scriptName,
}); daemonPort: options.daemonPort,
return `http://${hostname}:${options.daemonPort}`; publicBaseUrl: options.serviceProxyPublicBaseUrl,
}).proxyUrl;
} }
function isLoopbackListenHost(host: string | null | undefined): boolean { function isLoopbackListenHost(host: string | null | undefined): boolean {
@@ -450,7 +450,7 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
branchName: "feature-peer-env", branchName: "feature-peer-env",
scriptName, scriptName,
daemonPort: 6767, daemonPort: 6767,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}), }),
@@ -465,16 +465,24 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
const apiEnv = readEnvFile(apiEnvPath); const apiEnv = readEnvFile(apiEnvPath);
const webEnv = readEnvFile(webEnvPath); const webEnv = readEnvFile(webEnvPath);
expect(apiEnv.PASEO_SERVICE_API_URL).toBe("http://api.feature-peer-env.repo.localhost:6767"); expect(apiEnv.PASEO_SERVICE_API_URL).toBe(
expect(apiEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767"); "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_API_PORT).toEqual(expect.stringMatching(/^\d+$/));
expect(apiEnv.PASEO_SERVICE_WEB_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_URL).toBe(apiEnv.PASEO_SERVICE_API_URL);
expect(apiEnv.PASEO_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT); expect(apiEnv.PASEO_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT);
expect(apiEnv).not.toHaveProperty("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_API_URL).toBe(
expect(webEnv.PASEO_SERVICE_WEB_URL).toBe("http://web.feature-peer-env.repo.localhost:6767"); "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_API_PORT).toBe(apiEnv.PASEO_SERVICE_API_PORT);
expect(webEnv.PASEO_SERVICE_WEB_PORT).toBe(apiEnv.PASEO_SERVICE_WEB_PORT); expect(webEnv.PASEO_SERVICE_WEB_PORT).toBe(apiEnv.PASEO_SERVICE_WEB_PORT);
expect(webEnv.PASEO_URL).toBe(webEnv.PASEO_SERVICE_WEB_URL); 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(Number.isInteger(webPort)).toBe(true);
expect(routeStore.listRoutes()).toEqual([ expect(routeStore.listRoutes()).toEqual([
{ {
hostname: "api.feature-peer-env.repo.localhost", hostname: "api--feature-peer-env--repo.localhost",
port: apiPort, port: apiPort,
workspaceId: repoDir, workspaceId: repoDir,
projectSlug: "repo", projectSlug: "repo",
scriptName: "api", scriptName: "api",
}, },
{ {
hostname: "web.feature-peer-env.repo.localhost", hostname: "web--feature-peer-env--repo.localhost",
port: webPort, port: webPort,
workspaceId: repoDir, workspaceId: repoDir,
projectSlug: "repo", projectSlug: "repo",
@@ -420,13 +420,13 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(createTerminalCalls[0]?.env?.PASEO_PORT).toEqual(expect.any(String)); expect(createTerminalCalls[0]?.env?.PASEO_PORT).toEqual(expect.any(String));
expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1"); expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1");
expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe( 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( expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_PORT).toBe(
createTerminalCalls[0]?.env?.PASEO_PORT, createTerminalCalls[0]?.env?.PASEO_PORT,
); );
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_URL).toBe( 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), String(plannedAppServerPort),
); );
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_URL).toBe( 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", branchName: "feature-socket-service",
scriptName: "web", scriptName: "web",
daemonPort: null, daemonPort: null,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords), terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
}); });
@@ -541,7 +541,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-exit", branchName: "feature-script-exit",
scriptName: "typecheck", scriptName: "typecheck",
daemonPort: null, daemonPort: null,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}); });
@@ -580,7 +580,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-rerun", branchName: "feature-script-rerun",
scriptName: "typecheck", scriptName: "typecheck",
daemonPort: null, daemonPort: null,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}); });
@@ -600,7 +600,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-rerun", branchName: "feature-script-rerun",
scriptName: "typecheck", scriptName: "typecheck",
daemonPort: null, daemonPort: null,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}); });
@@ -659,7 +659,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-existing-terminal", branchName: "feature-script-existing-terminal",
scriptName: "typecheck", scriptName: "typecheck",
daemonPort: null, daemonPort: null,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}); });
@@ -701,7 +701,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-terminal-exit", branchName: "feature-script-terminal-exit",
scriptName: "typecheck", scriptName: "typecheck",
daemonPort: null, daemonPort: null,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}); });
@@ -738,7 +738,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-duplicate", branchName: "feature-script-duplicate",
scriptName: "typecheck", scriptName: "typecheck",
daemonPort: null, daemonPort: null,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}); });
@@ -751,7 +751,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-script-duplicate", branchName: "feature-script-duplicate",
scriptName: "typecheck", scriptName: "typecheck",
daemonPort: null, daemonPort: null,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}), }),
@@ -786,7 +786,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-socket-service", branchName: "feature-socket-service",
scriptName: "api", scriptName: "api",
daemonPort: 6767, daemonPort: 6767,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords), terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
}); });
@@ -794,7 +794,7 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(result.scriptName).toBe("api"); expect(result.scriptName).toBe("api");
expect(routeStore.listRoutes()).toEqual([ expect(routeStore.listRoutes()).toEqual([
{ {
hostname: "api.feature-socket-service.repo.localhost", hostname: "api--feature-socket-service--repo.localhost",
port: expect.any(Number), port: expect.any(Number),
workspaceId: repoDir, workspaceId: repoDir,
projectSlug: "repo", 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 () => { it("refreshes a stopped service port on respawn and updates the route", async () => {
writeFileSync( writeFileSync(
join(repoDir, "paseo.json"), join(repoDir, "paseo.json"),
@@ -852,7 +906,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-respawn-service", branchName: "feature-respawn-service",
scriptName: "api", scriptName: "api",
daemonPort: 6767, daemonPort: 6767,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}); });
@@ -864,7 +918,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-respawn-service", branchName: "feature-respawn-service",
scriptName: "worker", scriptName: "worker",
daemonPort: 6767, daemonPort: 6767,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}); });
@@ -889,7 +943,7 @@ describe("runAsyncWorktreeBootstrap", () => {
lifecycle: "stopped", lifecycle: "stopped",
exitCode: 0, 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({ const secondResult = await spawnWorkspaceScript({
repoRoot: repoDir, repoRoot: repoDir,
@@ -898,7 +952,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-respawn-service", branchName: "feature-respawn-service",
scriptName: "api", scriptName: "api",
daemonPort: 6767, daemonPort: 6767,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}); });
@@ -911,8 +965,8 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(secondPort).not.toBe(firstPort); expect(secondPort).not.toBe(firstPort);
expect(secondPort).toEqual(expect.any(Number)); expect(secondPort).toEqual(expect.any(Number));
expect(createTerminalCalls[2]?.env?.PASEO_SERVICE_WORKER_PORT).toBe(String(workerPort)); expect(createTerminalCalls[2]?.env?.PASEO_SERVICE_WORKER_PORT).toBe(String(workerPort));
expect(routeStore.getRouteEntry("api.feature-respawn-service.repo.localhost")).toMatchObject({ expect(routeStore.getRouteEntry("api--feature-respawn-service--repo.localhost")).toMatchObject({
hostname: "api.feature-respawn-service.repo.localhost", hostname: "api--feature-respawn-service--repo.localhost",
port: secondPort, port: secondPort,
workspaceId: repoDir, workspaceId: repoDir,
projectSlug: "repo", projectSlug: "repo",
@@ -955,20 +1009,20 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-before-rename", branchName: "feature-before-rename",
scriptName: "api", scriptName: "api",
daemonPort: 6767, daemonPort: 6767,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
}); });
const updateRoutesForBranchChange = createBranchChangeRouteHandler({ const updateRoutesForBranchChange = createBranchChangeRouteHandler({
routeStore, serviceProxy: routeStore,
onRoutesChanged: () => {}, onRoutesChanged: () => {},
}); });
updateRoutesForBranchChange(repoDir, "feature-before-rename", "feature-after-rename"); updateRoutesForBranchChange(repoDir, "feature-before-rename", "feature-after-rename");
expect(routeStore.listRoutesForWorkspace(repoDir)).toEqual([ expect(routeStore.listRoutesForWorkspace(repoDir)).toEqual([
expect.objectContaining({ expect.objectContaining({
hostname: "api.feature-after-rename.repo.localhost", hostname: "api--feature-after-rename--repo.localhost",
scriptName: "api", scriptName: "api",
}), }),
]); ]);
@@ -1020,7 +1074,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-collision-service", branchName: "feature-collision-service",
scriptName: "app-server", scriptName: "app-server",
daemonPort: 6767, daemonPort: 6767,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls), terminalManager: createStubTerminalManager(createTerminalCalls),
}), }),
@@ -1029,7 +1083,7 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(createTerminalCalls).toHaveLength(0); expect(createTerminalCalls).toHaveLength(0);
expect(routeStore.listRoutes()).toEqual([]); expect(routeStore.listRoutes()).toEqual([]);
expect( expect(
routeStore.getRouteEntry("app-server.feature-collision-service.repo.localhost"), routeStore.getRouteEntry("app-server--feature-collision-service--repo.localhost"),
).toBeNull(); ).toBeNull();
writeFileSync( writeFileSync(
@@ -1055,7 +1109,7 @@ describe("runAsyncWorktreeBootstrap", () => {
branchName: "feature-collision-service", branchName: "feature-collision-service",
scriptName: "app-server", scriptName: "app-server",
daemonPort: 6767, daemonPort: 6767,
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls), terminalManager: createStubTerminalManager(createTerminalCalls),
}); });
@@ -1108,7 +1162,7 @@ describe("runAsyncWorktreeBootstrap", () => {
scriptName: "web", scriptName: "web",
daemonPort: 6767, daemonPort: 6767,
daemonListenHost: "100.64.0.20", daemonListenHost: "100.64.0.20",
routeStore, serviceProxy: routeStore,
runtimeStore, runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls), terminalManager: createStubTerminalManager(createTerminalCalls),
}); });
@@ -1116,7 +1170,7 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(createTerminalCalls).toHaveLength(1); expect(createTerminalCalls).toHaveLength(1);
expect(createTerminalCalls[0]?.env?.HOST).toBe("0.0.0.0"); expect(createTerminalCalls[0]?.env?.HOST).toBe("0.0.0.0");
expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe( 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 { Logger } from "pino";
import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js"; import type { TerminalSession } from "../terminal/terminal.js";
import { buildPublicScriptHostname, buildScriptHostname } from "../utils/script-hostname.js";
import { import {
getScriptConfigs, getScriptConfigs,
getWorktreeTerminalSpecs, getWorktreeTerminalSpecs,
@@ -17,7 +16,7 @@ import {
type WorktreeSetupCommandResult, type WorktreeSetupCommandResult,
type WorktreeRuntimeEnv, type WorktreeRuntimeEnv,
} from "../utils/worktree.js"; } 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 { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js"; import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js";
import { import {
@@ -700,7 +699,7 @@ interface SpawnWorkspaceScriptOptions {
daemonPort?: number | null; daemonPort?: number | null;
daemonListenHost?: string | null; daemonListenHost?: string | null;
serviceProxyPublicBaseUrl?: string | null; serviceProxyPublicBaseUrl?: string | null;
routeStore: ScriptRouteStore; serviceProxy: ServiceProxySubsystem;
runtimeStore: WorkspaceScriptRuntimeStore; runtimeStore: WorkspaceScriptRuntimeStore;
terminalManager: TerminalManager; terminalManager: TerminalManager;
logger?: Logger; logger?: Logger;
@@ -724,7 +723,7 @@ async function setupServiceScriptRoute(params: {
daemonListenHost: string | null | undefined; daemonListenHost: string | null | undefined;
serviceProxyPublicBaseUrl: string | null | undefined; serviceProxyPublicBaseUrl: string | null | undefined;
existingRuntimeEntry: ReturnType<WorkspaceScriptRuntimeStore["get"]>; existingRuntimeEntry: ReturnType<WorkspaceScriptRuntimeStore["get"]>;
routeStore: ScriptRouteStore; serviceProxy: ServiceProxySubsystem;
}): Promise<ServiceScriptSetupResult> { }): Promise<ServiceScriptSetupResult> {
const { const {
scriptConfigs, scriptConfigs,
@@ -737,17 +736,8 @@ async function setupServiceScriptRoute(params: {
daemonListenHost, daemonListenHost,
serviceProxyPublicBaseUrl, serviceProxyPublicBaseUrl,
existingRuntimeEntry, existingRuntimeEntry,
routeStore, serviceProxy,
} = params; } = params;
const hostname = buildScriptHostname({ projectSlug, branchName, scriptName });
const publicHostname = serviceProxyPublicBaseUrl
? buildPublicScriptHostname({
projectSlug,
branchName,
scriptName,
publicBaseUrl: serviceProxyPublicBaseUrl,
})
: null;
const serviceDeclarations: Array<{ scriptName: string; port?: number }> = []; const serviceDeclarations: Array<{ scriptName: string; port?: number }> = [];
for (const [configuredScriptName, scriptConfig] of scriptConfigs) { for (const [configuredScriptName, scriptConfig] of scriptConfigs) {
@@ -794,16 +784,15 @@ async function setupServiceScriptRoute(params: {
peers, peers,
}); });
routeStore.registerRoute({ const registeredRoute = serviceProxy.registerWorkspaceService({
hostname,
publicHostname,
publicBaseUrl: serviceProxyPublicBaseUrl ?? null,
port, port,
workspaceId, workspaceId,
projectSlug, projectSlug,
branchName,
scriptName, scriptName,
publicBaseUrl: serviceProxyPublicBaseUrl ?? null,
}); });
return { hostname, port, env }; return { hostname: registeredRoute.hostname, port, env };
} }
async function acquireWorkspaceScriptTerminal(params: { async function acquireWorkspaceScriptTerminal(params: {
@@ -843,7 +832,7 @@ export async function spawnWorkspaceScript(
daemonPort, daemonPort,
daemonListenHost, daemonListenHost,
serviceProxyPublicBaseUrl, serviceProxyPublicBaseUrl,
routeStore, serviceProxy,
runtimeStore, runtimeStore,
terminalManager, terminalManager,
logger, logger,
@@ -886,7 +875,7 @@ export async function spawnWorkspaceScript(
daemonListenHost, daemonListenHost,
serviceProxyPublicBaseUrl, serviceProxyPublicBaseUrl,
existingRuntimeEntry, existingRuntimeEntry,
routeStore, serviceProxy,
}); });
hostname = serviceSetup.hostname; hostname = serviceSetup.hostname;
port = serviceSetup.port; port = serviceSetup.port;
@@ -923,7 +912,7 @@ export async function spawnWorkspaceScript(
disposeLifecycleListeners = null; disposeLifecycleListeners = null;
if (input.removeRoute && hostname) { if (input.removeRoute && hostname) {
routeStore.removeRouteForWorkspaceScript({ workspaceId, scriptName }); serviceProxy.removeWorkspaceService({ workspaceId, scriptName });
} }
runtimeStore.set({ runtimeStore.set({
workspaceId, workspaceId,
@@ -991,7 +980,7 @@ export async function spawnWorkspaceScript(
} catch (error) { } catch (error) {
disposeLifecycleListeners?.(); disposeLifecycleListeners?.();
if (routeRegistered && hostname) { if (routeRegistered && hostname) {
routeStore.removeRoute(hostname); serviceProxy.removeServiceRoutesByHostnames([hostname]);
} }
if (runtimeRegistered) { if (runtimeRegistered) {
runtimeStore.remove({ workspaceId, scriptName }); runtimeStore.remove({ workspaceId, scriptName });
@@ -1014,12 +1003,12 @@ export async function spawnWorkspaceScript(
export function teardownWorktreeScripts(options: { export function teardownWorktreeScripts(options: {
hostnames: string[]; hostnames: string[];
routeStore: ScriptRouteStore; serviceProxy: Pick<ServiceProxySubsystem, "removeServiceRoutesByHostnames">;
logger: Logger; logger: Logger;
}): void { }): void {
const { hostnames, routeStore, logger } = options; const { hostnames, serviceProxy, logger } = options;
serviceProxy.removeServiceRoutesByHostnames(hostnames);
for (const hostname of hostnames) { for (const hostname of hostnames) {
routeStore.removeRoute(hostname);
logger.info({ hostname }, "Removed script proxy route"); logger.info({ hostname }, "Removed script proxy route");
} }
} }
@@ -107,7 +107,7 @@ function createWorkflowForRequestTest(options: {
sessionLogger: createLogger(), sessionLogger: createLogger(),
terminalManager: null, terminalManager: null,
archiveWorkspaceRecord: async () => {}, archiveWorkspaceRecord: async () => {},
scriptRouteStore: null, serviceProxy: null,
scriptRuntimeStore: null, scriptRuntimeStore: null,
getDaemonTcpPort: null, getDaemonTcpPort: null,
getDaemonTcpHost: null, getDaemonTcpHost: null,
@@ -416,7 +416,7 @@ describe("create-agent worktree setup boundary", () => {
sessionLogger: createLogger(), sessionLogger: createLogger(),
terminalManager: null, terminalManager: null,
archiveWorkspaceRecord: async () => {}, archiveWorkspaceRecord: async () => {},
scriptRouteStore: null, serviceProxy: null,
scriptRuntimeStore: null, scriptRuntimeStore: null,
getDaemonTcpPort: null, getDaemonTcpPort: null,
getDaemonTcpHost: null, getDaemonTcpHost: null,
@@ -20,7 +20,7 @@ import {
getWorktreeSetupProgressResults, getWorktreeSetupProgressResults,
} from "./worktree-bootstrap.js"; } from "./worktree-bootstrap.js";
import type { TerminalManager } from "../terminal/terminal-manager.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 { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { GitHubService } from "../services/github-service.js"; import type { GitHubService } from "../services/github-service.js";
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js"; import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
@@ -103,7 +103,7 @@ interface CreatePaseoWorktreeInBackgroundDependencies {
sessionLogger: Logger; sessionLogger: Logger;
terminalManager: TerminalManager | null; terminalManager: TerminalManager | null;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>; archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
scriptRouteStore: ScriptRouteStore | null; serviceProxy: ServiceProxySubsystem | null;
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
getDaemonTcpPort: (() => number | null) | null; getDaemonTcpPort: (() => number | null) | null;
getDaemonTcpHost: (() => string | null) | null; getDaemonTcpHost: (() => string | null) | null;
@@ -13,7 +13,7 @@ describe("buildScriptHostname", () => {
branchName: null, branchName: null,
scriptName: "web", scriptName: "web",
}), }),
).toBe("web.paseo.localhost"); ).toBe("web--paseo.localhost");
}); });
it("omits the branch label for main and master", () => { it("omits the branch label for main and master", () => {
@@ -23,14 +23,14 @@ describe("buildScriptHostname", () => {
branchName: "main", branchName: "main",
scriptName: "web", scriptName: "web",
}), }),
).toBe("web.paseo.localhost"); ).toBe("web--paseo.localhost");
expect( expect(
buildScriptHostname({ buildScriptHostname({
projectSlug: "paseo", projectSlug: "paseo",
branchName: "master", branchName: "master",
scriptName: "web", scriptName: "web",
}), }),
).toBe("web.paseo.localhost"); ).toBe("web--paseo.localhost");
}); });
it("builds non-default branch hostnames with script, branch, and project labels", () => { it("builds non-default branch hostnames with script, branch, and project labels", () => {
@@ -40,7 +40,7 @@ describe("buildScriptHostname", () => {
branchName: "feature-auth", branchName: "feature-auth",
scriptName: "web", 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", () => { it("slugifies script, default branch project, and non-default branch labels", () => {
@@ -50,7 +50,7 @@ describe("buildScriptHostname", () => {
branchName: "Feature/Auth Flow", branchName: "Feature/Auth Flow",
scriptName: "Web/API @ Dev", 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", () => { it("accepts already slugified labels because slugify is idempotent", () => {
@@ -60,7 +60,7 @@ describe("buildScriptHostname", () => {
branchName: "feature-auth-flow", branchName: "feature-auth-flow",
scriptName: "web-api-dev", 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", () => { it("uses untitled as the hostname-label fallback when labels collapse to empty", () => {
@@ -70,7 +70,7 @@ describe("buildScriptHostname", () => {
branchName: "***", branchName: "***",
scriptName: "---", 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 { interface BuildScriptHostnameOptions {
projectSlug: string; projectSlug: string;
@@ -12,64 +16,21 @@ interface BuildPublicScriptHostnameOptions extends BuildScriptHostnameOptions {
publicBaseUrl: string; 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({ export function buildScriptHostname({
projectSlug, projectSlug,
branchName, branchName,
scriptName, scriptName,
}: BuildScriptHostnameOptions): string { }: BuildScriptHostnameOptions): string {
const serviceHostnameLabel = toHostnameLabel(scriptName); return buildLocalServiceHostname({ projectSlug, branchName, 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`;
} }
export function buildPublicScriptHostname({ export function buildPublicScriptHostname({
publicBaseUrl, publicBaseUrl,
...script ...script
}: BuildPublicScriptHostnameOptions): string { }: BuildPublicScriptHostnameOptions): string {
const base = new URL(publicBaseUrl); return buildPublicServiceHostname({ publicBaseUrl, ...script });
return `${toPublicServiceLabel(script)}.${base.hostname}`;
} }
export function buildPublicScriptProxyUrl(options: BuildPublicScriptHostnameOptions): string { export function buildPublicScriptProxyUrl(options: BuildPublicScriptHostnameOptions): string {
const base = new URL(options.publicBaseUrl); return buildPublicServiceProxyUrl(options);
const hostname = buildPublicScriptHostname(options);
const port = base.port ? `:${base.port}` : "";
return `${base.protocol}//${hostname}${port}`;
} }
@@ -97,12 +97,15 @@
"type": "object", "type": "object",
"properties": { "properties": {
"enabled": { "enabled": {
"description": "Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.",
"type": "boolean" "type": "boolean"
}, },
"listen": { "listen": {
"description": "Optional service-only listener address. Presence enables the standalone service proxy listener.",
"type": "string" "type": "string"
}, },
"publicBaseUrl": { "publicBaseUrl": {
"description": "Optional public base URL for service host aliases.",
"type": "string", "type": "string",
"format": "uri" "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: 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: # 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. `*.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_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_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_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. Script names are upper-cased and non-alphanumerics become `_`. Point your frontend at `$PASEO_SERVICE_API_URL` instead of hard-coding a port.