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