Unify workspace service URLs (#1288)
* Unify workspace service proxying * Clean up service proxy review issues
This commit is contained in:
+3
-1
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()));
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user