Support remote workspace service links
This commit is contained in:
@@ -28,7 +28,7 @@ const runningScript: WorkspaceScriptPayload = {
|
||||
type: "service",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
proxyUrl: "http://main.web.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
|
||||
@@ -50,7 +50,7 @@ const runningScript: WorkspaceScriptPayload = {
|
||||
type: "service",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
proxyUrl: "http://main.web.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
@@ -61,7 +61,7 @@ const stoppedScript: WorkspaceScriptPayload = {
|
||||
type: "service",
|
||||
hostname: "main.api.localhost",
|
||||
port: 3001,
|
||||
url: "http://main.api.localhost:6767",
|
||||
proxyUrl: "http://main.api.localhost:6767",
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ChevronDown, ExternalLink, Globe, LoaderCircle, Play, SquareTerminal }
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links";
|
||||
|
||||
type Script = WorkspaceDescriptor["scripts"][number];
|
||||
|
||||
@@ -42,6 +44,7 @@ export function WorkspaceScriptsButton({
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null;
|
||||
|
||||
const startScriptMutation = useMutation({
|
||||
mutationFn: async (scriptName: string) => {
|
||||
@@ -105,7 +108,8 @@ export function WorkspaceScriptsButton({
|
||||
{scripts.map((script) => {
|
||||
const isRunning = script.lifecycle === "running";
|
||||
const isService = (script.type ?? "service") === "service";
|
||||
const isLinkable = isService && isRunning && !!script.url;
|
||||
const serviceLink = resolveWorkspaceScriptLink({ script, activeConnection });
|
||||
const isLinkable = isService && isRunning && !!serviceLink.openUrl;
|
||||
const exitCode = script.exitCode ?? null;
|
||||
|
||||
let dotColor: string;
|
||||
@@ -133,7 +137,9 @@ export function WorkspaceScriptsButton({
|
||||
styles.scriptRow,
|
||||
hovered && isLinkable && styles.scriptRowHovered,
|
||||
]}
|
||||
onPress={isLinkable ? () => void openExternalUrl(script.url!) : undefined}
|
||||
onPress={
|
||||
isLinkable ? () => void openExternalUrl(serviceLink.openUrl!) : undefined
|
||||
}
|
||||
disabled={!isLinkable}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
@@ -156,9 +162,9 @@ export function WorkspaceScriptsButton({
|
||||
>
|
||||
{script.scriptName}
|
||||
</Text>
|
||||
{isService && isRunning && script.url ? (
|
||||
{isService && isRunning && serviceLink.labelUrl ? (
|
||||
<Text style={styles.scriptUrl} numberOfLines={1}>
|
||||
{script.url.replace(/^https?:\/\//, "")}
|
||||
{serviceLink.labelUrl.replace(/^https?:\/\//, "")}
|
||||
</Text>
|
||||
) : !isService && !isRunning && exitCode !== null && exitCode !== 0 ? (
|
||||
<Text style={styles.scriptUrl} numberOfLines={1}>
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("normalizeWorkspaceDescriptor", () => {
|
||||
type: "service" as const,
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
proxyUrl: "http://main.web.localhost:6767",
|
||||
lifecycle: "running" as const,
|
||||
health: "healthy" as const,
|
||||
exitCode: null,
|
||||
@@ -69,7 +69,7 @@ describe("normalizeWorkspaceDescriptor", () => {
|
||||
type: "service",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
proxyUrl: "http://main.web.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
@@ -118,7 +118,7 @@ describe("mergeWorkspaces", () => {
|
||||
type: "service",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
proxyUrl: "http://main.web.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
@@ -133,7 +133,7 @@ describe("mergeWorkspaces", () => {
|
||||
type: "service",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
proxyUrl: "http://main.web.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceScriptPayload } from "@server/shared/messages";
|
||||
import type { ActiveConnection } from "@/runtime/host-runtime";
|
||||
import { resolveWorkspaceScriptLink } from "./workspace-script-links";
|
||||
|
||||
const runningService: WorkspaceScriptPayload = {
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "feature.web.localhost",
|
||||
port: 3000,
|
||||
proxyUrl: "http://feature.web.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
};
|
||||
|
||||
function resolveLink(activeConnection: ActiveConnection | null) {
|
||||
return resolveWorkspaceScriptLink({
|
||||
script: runningService,
|
||||
activeConnection,
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveWorkspaceScriptLink", () => {
|
||||
it("uses the local proxy URL for loopback TCP connections", () => {
|
||||
expect(
|
||||
resolveLink({ type: "directTcp", endpoint: "localhost:6767", display: "localhost:6767" }),
|
||||
).toEqual({
|
||||
openUrl: "http://feature.web.localhost:6767",
|
||||
labelUrl: "http://feature.web.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the local proxy URL for socket and pipe connections", () => {
|
||||
expect(
|
||||
resolveLink({ type: "directSocket", endpoint: "/tmp/paseo.sock", display: "socket" }),
|
||||
).toEqual({
|
||||
openUrl: "http://feature.web.localhost:6767",
|
||||
labelUrl: "http://feature.web.localhost:6767",
|
||||
});
|
||||
});
|
||||
|
||||
it("degrades to daemon-host plus service port for direct network connections", () => {
|
||||
expect(
|
||||
resolveLink({
|
||||
type: "directTcp",
|
||||
endpoint: "mac-mini.tail123.ts.net:6767",
|
||||
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://feature.web.localhost:6767",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { parseHostPort } from "@server/shared/daemon-endpoints";
|
||||
import type { WorkspaceScriptPayload } from "@server/shared/messages";
|
||||
import type { ActiveConnection } from "@/runtime/host-runtime";
|
||||
|
||||
export type ResolvedWorkspaceScriptLink = {
|
||||
openUrl: string | null;
|
||||
labelUrl: string | null;
|
||||
};
|
||||
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
const normalizedHost = host.trim().toLowerCase();
|
||||
return normalizedHost === "localhost" || normalizedHost === "127.0.0.1" || normalizedHost === "::1";
|
||||
}
|
||||
|
||||
function buildDirectServiceUrl(endpoint: string, port: number): string | null {
|
||||
try {
|
||||
const { host, isIpv6 } = parseHostPort(endpoint);
|
||||
const base = isIpv6 ? `[${host}]` : host;
|
||||
return `http://${base}:${port}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
if (!activeConnection) {
|
||||
return { openUrl: null, labelUrl: script.proxyUrl };
|
||||
}
|
||||
|
||||
if (activeConnection.type === "relay") {
|
||||
return { openUrl: null, labelUrl: script.proxyUrl };
|
||||
}
|
||||
|
||||
if (activeConnection.type === "directSocket" || activeConnection.type === "directPipe") {
|
||||
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl };
|
||||
}
|
||||
|
||||
try {
|
||||
const { host } = parseHostPort(activeConnection.endpoint);
|
||||
if (isLoopbackHost(host)) {
|
||||
return { openUrl: script.proxyUrl, labelUrl: script.proxyUrl };
|
||||
}
|
||||
} catch {
|
||||
return { openUrl: null, labelUrl: script.proxyUrl };
|
||||
}
|
||||
|
||||
if (script.port === null) {
|
||||
return { openUrl: null, labelUrl: script.proxyUrl };
|
||||
}
|
||||
|
||||
const directUrl = buildDirectServiceUrl(activeConnection.endpoint, script.port);
|
||||
return {
|
||||
openUrl: directUrl,
|
||||
labelUrl: directUrl ?? script.proxyUrl,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user