feat(app): improve workspace service controls
Keep service route choices independent of daemon transport and persist the selected route per host. Use the shared web overlay stack so menus, tooltips, and toasts layer predictably. Track proxy route changes from the Git branch rather than the workspace title to avoid transient service health changes.
This commit is contained in:
@@ -17,33 +17,64 @@ const runningService: WorkspaceScriptPayload = {
|
||||
terminalId: null,
|
||||
};
|
||||
|
||||
function resolveLink(activeConnection: ActiveConnection | null) {
|
||||
return resolveWorkspaceScriptLink({
|
||||
script: runningService,
|
||||
activeConnection,
|
||||
});
|
||||
function resolveLink(
|
||||
activeConnection: ActiveConnection | null,
|
||||
script: WorkspaceScriptPayload = runningService,
|
||||
) {
|
||||
return resolveWorkspaceScriptLink({ script, activeConnection });
|
||||
}
|
||||
|
||||
describe("resolveWorkspaceScriptLink", () => {
|
||||
it("uses the local proxy URL for loopback TCP connections", () => {
|
||||
it("defaults to the memorable Paseo URL locally and keeps direct as a fallback", () => {
|
||||
expect(
|
||||
resolveLink({ type: "directTcp", endpoint: "localhost:6767", display: "localhost:6767" }),
|
||||
).toEqual({
|
||||
openUrl: "http://web--feature--paseo.localhost:6767",
|
||||
labelUrl: "http://web--feature--paseo.localhost:6767",
|
||||
primary: {
|
||||
kind: "paseo",
|
||||
label: "web--feature--paseo.localhost:6767",
|
||||
url: "http://web--feature--paseo.localhost:6767",
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
kind: "paseo",
|
||||
label: "web--feature--paseo.localhost:6767",
|
||||
url: "http://web--feature--paseo.localhost:6767",
|
||||
},
|
||||
{ kind: "direct", label: "localhost:3000", url: "http://localhost:3000" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the local proxy URL for socket and pipe connections", () => {
|
||||
it("defaults to an explicitly configured reverse proxy", () => {
|
||||
const publicUrl = "https://web--feature--paseo.services.example.com";
|
||||
expect(
|
||||
resolveLink({ type: "directSocket", endpoint: "/tmp/paseo.sock", display: "socket" }),
|
||||
resolveLink(
|
||||
{ type: "directSocket", endpoint: "/tmp/paseo.sock", display: "socket" },
|
||||
{ ...runningService, publicProxyUrl: publicUrl, proxyUrl: publicUrl },
|
||||
),
|
||||
).toEqual({
|
||||
openUrl: "http://web--feature--paseo.localhost:6767",
|
||||
labelUrl: "http://web--feature--paseo.localhost:6767",
|
||||
primary: {
|
||||
kind: "public",
|
||||
label: "web--feature--paseo.services.example.com",
|
||||
url: publicUrl,
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
kind: "public",
|
||||
label: "web--feature--paseo.services.example.com",
|
||||
url: publicUrl,
|
||||
},
|
||||
{
|
||||
kind: "paseo",
|
||||
label: "web--feature--paseo.localhost:6767",
|
||||
url: "http://web--feature--paseo.localhost:6767",
|
||||
},
|
||||
{ kind: "direct", label: "localhost:3000", url: "http://localhost:3000" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("degrades to daemon-host plus service port for direct network connections", () => {
|
||||
it("uses the daemon host and service port over a direct network connection", () => {
|
||||
expect(
|
||||
resolveLink({
|
||||
type: "directTcp",
|
||||
@@ -51,124 +82,123 @@ describe("resolveWorkspaceScriptLink", () => {
|
||||
display: "mac-mini.tail123.ts.net:6767",
|
||||
}),
|
||||
).toEqual({
|
||||
openUrl: "http://mac-mini.tail123.ts.net:3000",
|
||||
labelUrl: "http://mac-mini.tail123.ts.net:3000",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the local proxy URL but disables opening over relay", () => {
|
||||
expect(
|
||||
resolveLink({ type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" }),
|
||||
).toEqual({
|
||||
openUrl: null,
|
||||
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",
|
||||
primary: {
|
||||
kind: "paseo",
|
||||
label: "web--feature--paseo.localhost:6767",
|
||||
url: "http://web--feature--paseo.localhost:6767",
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
kind: "paseo",
|
||||
label: "web--feature--paseo.localhost:6767",
|
||||
url: "http://web--feature--paseo.localhost:6767",
|
||||
},
|
||||
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",
|
||||
{
|
||||
kind: "direct",
|
||||
label: "mac-mini.tail123.ts.net:3000",
|
||||
url: "http://mac-mini.tail123.ts.net:3000",
|
||||
},
|
||||
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", () => {
|
||||
it("offers the reverse proxy and direct route over a direct network connection", () => {
|
||||
const publicUrl = "https://web--feature--paseo.services.example.com";
|
||||
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",
|
||||
});
|
||||
resolveLink(
|
||||
{ type: "directTcp", endpoint: "mac-mini.tail123.ts.net:6767", display: "remote" },
|
||||
{ ...runningService, publicProxyUrl: publicUrl, proxyUrl: publicUrl },
|
||||
).targets,
|
||||
).toEqual([
|
||||
{ kind: "public", label: "web--feature--paseo.services.example.com", url: publicUrl },
|
||||
{
|
||||
kind: "paseo",
|
||||
label: "web--feature--paseo.localhost:6767",
|
||||
url: "http://web--feature--paseo.localhost:6767",
|
||||
},
|
||||
{
|
||||
kind: "direct",
|
||||
label: "mac-mini.tail123.ts.net:3000",
|
||||
url: "http://mac-mini.tail123.ts.net:3000",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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",
|
||||
it("keeps service routes available independently of a relay connection", () => {
|
||||
const relay: ActiveConnection = {
|
||||
type: "relay",
|
||||
endpoint: "relay.paseo.sh:443",
|
||||
display: "relay",
|
||||
};
|
||||
expect(resolveLink(relay)).toEqual({
|
||||
primary: {
|
||||
kind: "paseo",
|
||||
label: "web--feature--paseo.localhost:6767",
|
||||
url: "http://web--feature--paseo.localhost:6767",
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
kind: "paseo",
|
||||
label: "web--feature--paseo.localhost:6767",
|
||||
url: "http://web--feature--paseo.localhost:6767",
|
||||
},
|
||||
{ kind: "direct", label: "localhost:3000", url: "http://localhost:3000" },
|
||||
],
|
||||
});
|
||||
|
||||
const publicUrl = "https://web--feature--paseo.services.example.com";
|
||||
expect(
|
||||
resolveWorkspaceScriptLink({
|
||||
script: oldPayload,
|
||||
activeConnection: { type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" },
|
||||
}),
|
||||
resolveLink(relay, { ...runningService, publicProxyUrl: publicUrl, proxyUrl: publicUrl }),
|
||||
).toEqual({
|
||||
openUrl: "https://web--feature--paseo.services.example.com",
|
||||
labelUrl: "https://web--feature--paseo.services.example.com",
|
||||
primary: {
|
||||
kind: "public",
|
||||
label: "web--feature--paseo.services.example.com",
|
||||
url: publicUrl,
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
kind: "public",
|
||||
label: "web--feature--paseo.services.example.com",
|
||||
url: publicUrl,
|
||||
},
|
||||
{
|
||||
kind: "paseo",
|
||||
label: "web--feature--paseo.localhost:6767",
|
||||
url: "http://web--feature--paseo.localhost:6767",
|
||||
},
|
||||
{ kind: "direct", label: "localhost:3000", url: "http://localhost:3000" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies proxyUrl from older daemons", () => {
|
||||
const { localProxyUrl: _local, publicProxyUrl: _public, ...legacyLocal } = runningService;
|
||||
expect(resolveLink(null, legacyLocal).targets.map((target) => target.kind)).toEqual([
|
||||
"paseo",
|
||||
"direct",
|
||||
]);
|
||||
|
||||
const publicUrl = "https://web--feature--paseo.services.example.com";
|
||||
expect(
|
||||
resolveLink(
|
||||
{ type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" },
|
||||
{ ...legacyLocal, proxyUrl: publicUrl },
|
||||
).primary,
|
||||
).toEqual({
|
||||
kind: "public",
|
||||
label: "web--feature--paseo.services.example.com",
|
||||
url: publicUrl,
|
||||
});
|
||||
});
|
||||
|
||||
it("has no routes for stopped services or plain scripts", () => {
|
||||
expect(resolveLink(null, { ...runningService, lifecycle: "stopped" })).toEqual({
|
||||
primary: null,
|
||||
targets: [],
|
||||
});
|
||||
expect(resolveLink(null, { ...runningService, type: "script" })).toEqual({
|
||||
primary: null,
|
||||
targets: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,17 @@ import { parseHostPort } from "@getpaseo/protocol/daemon-endpoints";
|
||||
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
|
||||
import type { ActiveConnection } from "@/runtime/host-runtime";
|
||||
|
||||
export type WorkspaceScriptLinkKind = "public" | "paseo" | "direct";
|
||||
|
||||
export interface WorkspaceScriptLinkTarget {
|
||||
kind: WorkspaceScriptLinkKind;
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ResolvedWorkspaceScriptLink {
|
||||
openUrl: string | null;
|
||||
labelUrl: string | null;
|
||||
primary: WorkspaceScriptLinkTarget | null;
|
||||
targets: WorkspaceScriptLinkTarget[];
|
||||
}
|
||||
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
@@ -15,77 +23,70 @@ function isLoopbackHost(host: string): boolean {
|
||||
}
|
||||
|
||||
function isLocalOnlyUrl(url: string | null | undefined): boolean {
|
||||
if (!url) {
|
||||
return true;
|
||||
}
|
||||
if (!url) return true;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
return isLoopbackHost(hostname) || hostname.endsWith(".localhost");
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDirectServiceUrl(endpoint: string, port: number): string | null {
|
||||
function stripUrlProtocol(url: string): string {
|
||||
return url.replace(/^https?:\/\//, "");
|
||||
}
|
||||
|
||||
function buildDirectServiceUrl(
|
||||
activeConnection: ActiveConnection | null,
|
||||
port: number | null,
|
||||
): string | null {
|
||||
if (port === null) return null;
|
||||
if (activeConnection?.type !== "directTcp") {
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
try {
|
||||
const { host, isIpv6 } = parseHostPort(endpoint);
|
||||
const base = isIpv6 ? `[${host}]` : host;
|
||||
const { host, isIpv6 } = parseHostPort(activeConnection.endpoint);
|
||||
let base = host;
|
||||
if (isLoopbackHost(host)) {
|
||||
base = "localhost";
|
||||
} else if (isIpv6) {
|
||||
base = `[${host}]`;
|
||||
}
|
||||
return `http://${base}:${port}`;
|
||||
} catch {
|
||||
return null;
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
}
|
||||
|
||||
function addTarget(
|
||||
targets: WorkspaceScriptLinkTarget[],
|
||||
kind: WorkspaceScriptLinkKind,
|
||||
url: string | null | undefined,
|
||||
): void {
|
||||
if (!url || targets.some((target) => target.url === url)) return;
|
||||
targets.push({ kind, label: stripUrlProtocol(url), url });
|
||||
}
|
||||
|
||||
export function resolveWorkspaceScriptLink(input: {
|
||||
script: WorkspaceScriptPayload;
|
||||
activeConnection: ActiveConnection | null;
|
||||
}): ResolvedWorkspaceScriptLink {
|
||||
const { script, activeConnection } = input;
|
||||
if (script.type !== "service" || script.lifecycle !== "running") {
|
||||
return { openUrl: null, labelUrl: null };
|
||||
return { primary: null, targets: [] };
|
||||
}
|
||||
|
||||
if (!activeConnection) {
|
||||
return { openUrl: null, labelUrl: script.proxyUrl };
|
||||
}
|
||||
|
||||
const localProxyUrl = script.localProxyUrl ?? script.proxyUrl;
|
||||
// COMPAT(workspaceScriptSplitUrls): added in v0.2.0, remove after 2027-01-21.
|
||||
// Old daemons only send proxyUrl, so classify it by reachability.
|
||||
const localProxyUrl =
|
||||
script.localProxyUrl ?? (isLocalOnlyUrl(script.proxyUrl) ? script.proxyUrl : null);
|
||||
const publicProxyUrl =
|
||||
script.publicProxyUrl ?? (!isLocalOnlyUrl(script.proxyUrl) ? script.proxyUrl : null);
|
||||
const preferredProxyUrl = publicProxyUrl ?? localProxyUrl ?? script.proxyUrl;
|
||||
|
||||
if (activeConnection.type === "relay") {
|
||||
return {
|
||||
openUrl: publicProxyUrl,
|
||||
labelUrl: publicProxyUrl ?? localProxyUrl ?? script.proxyUrl,
|
||||
};
|
||||
}
|
||||
const targets: WorkspaceScriptLinkTarget[] = [];
|
||||
addTarget(targets, "public", publicProxyUrl);
|
||||
addTarget(targets, "paseo", localProxyUrl);
|
||||
addTarget(targets, "direct", buildDirectServiceUrl(activeConnection, script.port));
|
||||
|
||||
if (activeConnection.type === "directSocket" || activeConnection.type === "directPipe") {
|
||||
return { openUrl: localProxyUrl, labelUrl: localProxyUrl };
|
||||
}
|
||||
|
||||
try {
|
||||
const { host } = parseHostPort(activeConnection.endpoint);
|
||||
if (isLoopbackHost(host)) {
|
||||
return { openUrl: localProxyUrl, labelUrl: localProxyUrl };
|
||||
}
|
||||
} catch {
|
||||
return { openUrl: null, labelUrl: preferredProxyUrl };
|
||||
}
|
||||
|
||||
if (publicProxyUrl) {
|
||||
return { openUrl: publicProxyUrl, labelUrl: publicProxyUrl };
|
||||
}
|
||||
|
||||
if (script.port === null) {
|
||||
return { openUrl: null, labelUrl: script.proxyUrl };
|
||||
}
|
||||
|
||||
const directUrl = buildDirectServiceUrl(activeConnection.endpoint, script.port);
|
||||
return {
|
||||
openUrl: directUrl,
|
||||
labelUrl: directUrl ?? preferredProxyUrl,
|
||||
};
|
||||
return { primary: targets[0] ?? null, targets };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user