From e5f5c40fdc3f82a828a75f5852664ffa85ae9b35 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 11:51:02 +0200 Subject: [PATCH] Add scoped SSH preview port forwarding Co-authored-by: codex --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 4 + apps/desktop/src/ipc/channels.ts | 2 + .../src/ipc/methods/sshEnvironment.test.ts | 38 ++ .../desktop/src/ipc/methods/sshEnvironment.ts | 35 ++ apps/desktop/src/preload.ts | 15 +- apps/desktop/src/ssh/DesktopSshEnvironment.ts | 15 + .../desktop/src/wsl/DesktopWslBackend.test.ts | 1 + apps/mobile/src/connection/platform.ts | 7 + apps/server/src/preview/PortScanner.test.ts | 1 + .../src/browser/browserTargetResolver.test.ts | 183 ------- apps/web/src/browser/browserTargetResolver.ts | 216 ++++---- .../preview/PreviewAutomationHosts.tsx | 135 ++--- .../src/components/preview/PreviewView.tsx | 38 +- .../preview/closePreviewSession.test.ts | 11 +- .../components/preview/closePreviewSession.ts | 3 + .../preview/openDiscoveredPort.test.ts | 94 ++++ .../components/preview/openDiscoveredPort.ts | 33 +- .../preview/useDiscoveredLocalServers.ts | 6 +- apps/web/src/connection/platform.ts | 28 ++ packages/client-runtime/package.json | 4 + .../client-runtime/src/connection/layer.ts | 2 + .../src/connection/onboarding.test.ts | 1 + .../src/connection/registry.test.ts | 1 + .../src/connection/resolver.test.ts | 1 + .../src/platform/capabilities.ts | 9 + packages/client-runtime/src/preview/index.ts | 1 + .../client-runtime/src/preview/router.test.ts | 249 +++++++++ packages/client-runtime/src/preview/router.ts | 270 ++++++++++ packages/contracts/src/ipc.ts | 31 ++ packages/contracts/src/previewAutomation.ts | 2 +- packages/shared/src/Net.test.ts | 12 + packages/shared/src/Net.ts | 6 + packages/ssh/src/tunnel.test.ts | 62 +++ packages/ssh/src/tunnel.ts | 472 +++++++++++++++--- scripts/dev-runner.test.ts | 1 + 35 files changed, 1516 insertions(+), 473 deletions(-) delete mode 100644 apps/web/src/browser/browserTargetResolver.test.ts create mode 100644 apps/web/src/components/preview/openDiscoveredPort.test.ts create mode 100644 packages/client-runtime/src/preview/index.ts create mode 100644 packages/client-runtime/src/preview/router.test.ts create mode 100644 packages/client-runtime/src/preview/router.ts diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index e478d0c6eff..5834a5452ce 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -14,6 +14,7 @@ import { setTailscaleServeEnabled, } from "./methods/serverExposure.ts"; import { + acquireSshPortForward, bootstrapSshBearerSession, disconnectSshEnvironment, discoverSshHosts, @@ -21,6 +22,7 @@ import { fetchSshEnvironmentDescriptor, fetchSshSessionState, issueSshWebSocketTicket, + releaseSshPortForward, resolveSshPasswordPrompt, } from "./methods/sshEnvironment.ts"; import { @@ -62,6 +64,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(discoverSshHosts); yield* ipc.handle(ensureSshEnvironment); yield* ipc.handle(disconnectSshEnvironment); + yield* ipc.handle(acquireSshPortForward); + yield* ipc.handle(releaseSshPortForward); yield* ipc.handle(fetchSshEnvironmentDescriptor); yield* ipc.handle(bootstrapSshBearerSession); yield* ipc.handle(fetchSshSessionState); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 0d5c79cf55a..5e3e7d287b1 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -24,6 +24,8 @@ export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalo export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; +export const ACQUIRE_SSH_PORT_FORWARD_CHANNEL = "desktop:acquire-ssh-port-forward"; +export const RELEASE_SSH_PORT_FORWARD_CHANNEL = "desktop:release-ssh-port-forward"; export const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; export const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; export const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts index fa53486d5e2..9fbf62f82e2 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts @@ -8,9 +8,12 @@ import * as Option from "effect/Option"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { + acquireSshPortForward, DesktopSshEnvironmentRequestError, fetchSshEnvironmentDescriptor, + releaseSshPortForward, } from "./sshEnvironment.ts"; +import { DesktopSshEnvironment } from "../../ssh/DesktopSshEnvironment.ts"; function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) { return HttpClientResponse.fromWeb( @@ -34,6 +37,41 @@ function makeHttpClientLayer( } describe("SSH environment IPC", () => { + it.effect("acquires and releases SSH port-forward leases", () => { + const calls: unknown[] = []; + const target = { + alias: "devbox", + hostname: "devbox.example.test", + username: "developer", + port: 22, + } as const; + const layer = Layer.succeed( + DesktopSshEnvironment, + DesktopSshEnvironment.of({ + discoverHosts: () => Effect.succeed([]), + ensureEnvironment: () => Effect.die("unused"), + disconnectEnvironment: () => Effect.die("unused"), + acquirePortForward: (receivedTarget, remotePort) => + Effect.sync(() => { + calls.push([receivedTarget, remotePort]); + return { leaseId: "lease-1", localPort: 42_173, remotePort }; + }), + releasePortForward: (leaseId) => Effect.sync(() => void calls.push(leaseId)), + }), + ); + + return Effect.gen(function* () { + const acquired = yield* acquireSshPortForward.handler({ target, remotePort: 5173 }); + assert.deepEqual(acquired, { + leaseId: "lease-1", + localPort: 42_173, + remotePort: 5173, + }); + yield* releaseSshPortForward.handler({ leaseId: "lease-1" }); + assert.deepEqual(calls, [[target, 5173], "lease-1"]); + }).pipe(Effect.provide(layer)); + }); + it.effect("fetches and decodes the remote environment descriptor", () => { const requestUrls: string[] = []; const layer = makeHttpClientLayer((request) => diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 9c9af2a4e2b..1f5da0249b6 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -15,6 +15,9 @@ import { DesktopSshEnvironmentEnsureResultSchema, DesktopSshEnvironmentTargetSchema, DesktopSshHttpBaseUrlInputSchema, + DesktopSshPortForwardAcquireInputSchema, + DesktopSshPortForwardAcquireResultSchema, + DesktopSshPortForwardReleaseInputSchema, DesktopSshPasswordPromptCancelledType, DesktopSshPasswordPromptResolutionInputSchema, ExecutionEnvironmentDescriptor, @@ -149,6 +152,38 @@ export const disconnectSshEnvironment = DesktopIpc.makeIpcMethod({ }), }); +export const acquireSshPortForward = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.ACQUIRE_SSH_PORT_FORWARD_CHANNEL, + payload: DesktopSshPortForwardAcquireInputSchema, + result: DesktopSshPortForwardAcquireResultSchema, + handler: Effect.fn("desktop.ipc.sshEnvironment.acquirePortForward")(function* ({ + target, + remotePort, + }) { + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; + return yield* sshEnvironment.acquirePortForward(target, remotePort).pipe( + Effect.catch((error) => + DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(error) + ? Effect.succeed({ + type: DesktopSshPasswordPromptCancelledType, + message: error.message, + }) + : Effect.fail(error), + ), + ); + }), +}); + +export const releaseSshPortForward = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.RELEASE_SSH_PORT_FORWARD_CHANNEL, + payload: DesktopSshPortForwardReleaseInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.sshEnvironment.releasePortForward")(function* ({ leaseId }) { + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; + yield* sshEnvironment.releasePortForward(leaseId); + }), +}); + export const fetchSshEnvironmentDescriptor = DesktopIpc.makeIpcMethod({ channel: IpcChannels.FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, payload: DesktopSshHttpBaseUrlInputSchema, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9f92cfcf2fa..b705a73db34 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -11,7 +11,7 @@ import * as IpcChannels from "./ipc/channels.ts"; exposeClerkBridge({ passkeys: true }); -function unwrapEnsureSshEnvironmentResult(result: unknown) { +function unwrapSshPasswordPromptResult(result: unknown): T { if ( typeof result === "object" && result !== null && @@ -24,7 +24,7 @@ function unwrapEnsureSshEnvironmentResult(result: unknown) { : "SSH authentication cancelled."; throw new Error(message); } - return result as Awaited>; + return result as T; } contextBridge.exposeInMainWorld("desktopBridge", { @@ -53,7 +53,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { clearConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL), discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), ensureSshEnvironment: async (target, options) => - unwrapEnsureSshEnvironmentResult( + unwrapSshPasswordPromptResult>>( await ipcRenderer.invoke(IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, { target, ...(options === undefined ? {} : { options }), @@ -61,6 +61,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { ), disconnectSshEnvironment: (target) => ipcRenderer.invoke(IpcChannels.DISCONNECT_SSH_ENVIRONMENT_CHANNEL, target), + acquireSshPortForward: async (target, remotePort) => + unwrapSshPasswordPromptResult>>( + await ipcRenderer.invoke(IpcChannels.ACQUIRE_SSH_PORT_FORWARD_CHANNEL, { + target, + remotePort, + }), + ), + releaseSshPortForward: (leaseId) => + ipcRenderer.invoke(IpcChannels.RELEASE_SSH_PORT_FORWARD_CHANNEL, { leaseId }), fetchSshEnvironmentDescriptor: (httpBaseUrl) => ipcRenderer.invoke(IpcChannels.FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, { httpBaseUrl }), bootstrapSshBearerSession: (httpBaseUrl, credential) => diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 31e84ae995e..68cbd6f6666 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -61,6 +61,11 @@ export class DesktopSshEnvironment extends Context.Service< readonly disconnectEnvironment: ( target: DesktopSshEnvironmentTarget, ) => Effect.Effect; + readonly acquirePortForward: ( + target: DesktopSshEnvironmentTarget, + remotePort: number, + ) => Effect.Effect; + readonly releasePortForward: (leaseId: string) => Effect.Effect; } >()("@t3tools/desktop/ssh/DesktopSshEnvironment") {} @@ -152,6 +157,16 @@ export const make = Effect.gen(function* () { Effect.provide(runtimeContext), Effect.withSpan("desktop.ssh.disconnectEnvironment"), ), + acquirePortForward: (target, remotePort) => + manager + .acquirePortForward(target, remotePort) + .pipe( + Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), + Effect.provide(runtimeContext), + Effect.withSpan("desktop.ssh.acquirePortForward"), + ), + releasePortForward: (leaseId) => + manager.releasePortForward(leaseId).pipe(Effect.withSpan("desktop.ssh.releasePortForward")), }); }); diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index 2f58c6adcfb..1820c8c152a 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -75,6 +75,7 @@ const backendConfigurationLayer = Layer.succeed( ); const netLayer = Layer.succeed(NetService.NetService, { + hasListenerOnHost: () => Effect.succeed(false), canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), reserveLoopbackPort: () => Effect.succeed(41773), diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index cc51b56ee70..af19e53a231 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -161,6 +161,13 @@ const capabilitiesLayer = Layer.effectContext( }), ), disconnect: () => Effect.void, + forwardPort: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "unsupported", + detail: "SSH environments are only available in the desktop app.", + }), + ), }), ), ); diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 69b5729164d..ab38d77c06e 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -35,6 +35,7 @@ const makeProbeFailureLayer = (run: ProcessRunner.ProcessRunner["Service"]["run" Layer.mergeAll( Layer.succeed(ProcessRunner.ProcessRunner, { run }), Layer.succeed(Net.NetService, { + hasListenerOnHost: () => Effect.succeed(false), canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), reserveLoopbackPort: () => Effect.succeed(40_000), diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts deleted file mode 100644 index 6f89d86df88..00000000000 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { EnvironmentId } from "@t3tools/contracts"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const readPreparedConnection = vi.fn(); - -vi.mock("~/state/session", () => ({ readPreparedConnection })); - -describe("browser target resolver", () => { - beforeEach(() => readPreparedConnection.mockReset()); - - it("maps environment ports onto a private network host", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "environment-port", - port: 5173, - path: "/dashboard", - }), - ).toEqual({ - requestedUrl: "http://localhost:5173/dashboard", - resolvedUrl: "http://192.168.1.25:5173/dashboard", - resolutionKind: "direct-private-network", - environmentId: "environment-1", - }); - }); - - it("maps localhost URL navigation onto a remote Tailscale IPv4 host", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "url", - url: "http://localhost:5173/dashboard?mode=test#results", - }), - ).toEqual({ - requestedUrl: "http://localhost:5173/dashboard?mode=test#results", - resolvedUrl: "http://100.65.180.100:5173/dashboard?mode=test#results", - resolutionKind: "direct-private-network", - environmentId: "environment-1", - }); - }); - - it("preserves URL credentials when mapping localhost onto a remote host", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "url", - url: "http://user:p%40ss@localhost:5173/dashboard", - }).resolvedUrl, - ).toBe("http://user:p%40ss@100.65.180.100:5173/dashboard"); - }); - - it("maps credentialed localhost URLs onto private IPv6 hosts", async () => { - readPreparedConnection.mockReturnValue({ - httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", - }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "url", - url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results", - }).resolvedUrl, - ).toBe("http://user:p%40ss@[fd7a:115c:a1e0::53]:5173/dashboard?mode=test#results"); - }); - - it("maps schemeless localhost navigation onto a remote environment host", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "url", - url: "localhost:3000/app", - }).resolvedUrl, - ).toBe("http://192.168.1.25:3000/app"); - }); - - it("keeps localhost navigation local for a local environment", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "url", - url: "localhost:3000/app", - }), - ).toEqual({ - requestedUrl: "localhost:3000/app", - resolvedUrl: "localhost:3000/app", - resolutionKind: "direct", - environmentId: "environment-1", - }); - }); - - it("keeps localhost navigation local for the full IPv4 loopback range", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.2:3773" }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "url", - url: "http://localhost:3000/app", - }), - ).toEqual({ - requestedUrl: "http://localhost:3000/app", - resolvedUrl: "http://localhost:3000/app", - resolutionKind: "direct", - environmentId: "environment-1", - }); - }); - - it("refuses public relay hosts until the authenticated gateway exists", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect(() => - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "environment-port", - port: 5173, - }), - ).toThrow(/authenticated preview gateway/); - expect(() => - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "url", - url: "http://localhost:5173", - }), - ).toThrow(/authenticated preview gateway/); - }); - - it("normalizes schemeless localhost server-picker values", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://localhost:3773" }); - const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); - expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:5173")).toBe( - "http://localhost:5173/", - ); - expect( - resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "0.0.0.0:3000/app"), - ).toBe("http://localhost:3000/app"); - }); - - it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); - const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); - expect( - resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:5173/app?x=1#top"), - ).toBe("http://localhost:5173/app?x=1#top"); - }); - - it("normalizes public URLs without treating them as environment ports", async () => { - const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); - expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "example.com/app")).toBe( - "https://example.com/app", - ); - }); - - it("supports private IPv6 environment hosts", async () => { - readPreparedConnection.mockReturnValue({ - httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", - }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "environment-port", - port: 5173, - path: "/app?mode=test", - }).resolvedUrl, - ).toBe("http://[fd7a:115c:a1e0::53]:5173/app?mode=test"); - }); - - it("supports a local IPv6 environment host", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "environment-port", - port: 5173, - }).resolvedUrl, - ).toBe("http://[::1]:5173/"); - }); - - it("leaves malformed input for the normal navigation error path", async () => { - const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); - expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), " ")).toBe(" "); - }); -}); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 9b201dbdbae..670479fa760 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -1,141 +1,127 @@ +import { + EnvironmentPortRouter, + type EnvironmentPortRouteRequest, +} from "@t3tools/client-runtime/preview"; +import { + createRuntimeCommand, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import type { BrowserNavigationTarget, EnvironmentId, PreviewUrlResolution, } from "@t3tools/contracts"; -import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; +import { normalizePreviewUrl } from "@t3tools/shared/preview"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Scope from "effect/Scope"; +import { connectionAtomRuntime } from "~/connection/runtime"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; import { readPreparedConnection } from "~/state/session"; -const normalizeHostname = (host: string): string => host.toLowerCase().replace(/^\[|\]$/g, ""); +interface AcquiredRoute { + readonly resolution: PreviewUrlResolution; + readonly scope: Scope.Closeable; +} -const parseIpv4Address = (host: string): readonly number[] | null => { - const parts = normalizeHostname(host).split(".").map(Number); - return parts.length === 4 && - parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) - ? parts - : null; -}; +export interface BrowserNavigationRoute { + readonly resolution: PreviewUrlResolution; + readonly commit: (tabId: string) => Promise; + readonly release: () => Promise; +} -const isLocalLoopbackHost = (host: string): boolean => { - const normalized = normalizeHostname(host); - if (normalized === "localhost" || normalized === "::1") return true; - return parseIpv4Address(normalized)?.[0] === 127; -}; +const activeRoutes = new Map(); -const isPrivateNetworkHost = (host: string): boolean => { - const normalized = normalizeHostname(host); - if (isLocalLoopbackHost(normalized) || normalized.endsWith(".local")) { - return true; - } - if (normalized.endsWith(".ts.net")) return true; - const parts = parseIpv4Address(normalized); - if (parts) { - return ( - parts[0] === 10 || - (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) || - (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || - (parts[0] === 192 && parts[1] === 168) || - (parts[0] === 169 && parts[1] === 254) - ); - } - const firstIpv6Token = normalized.split(":", 1)[0] ?? ""; - if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false; - const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16); - return ( - Number.isInteger(firstIpv6Hextet) && - ((firstIpv6Hextet & 0xfe00) === 0xfc00 || (firstIpv6Hextet & 0xffc0) === 0xfe80) - ); -}; +const closeRouteScope = (scope: Scope.Closeable): Promise => + Effect.runPromise(Scope.close(scope, Exit.void)); -const readEnvironmentUrl = (environmentId: EnvironmentId): URL => { - const connection = readPreparedConnection(environmentId); - if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); - return new URL(connection.httpBaseUrl); -}; +const acquireRouteCommand = createRuntimeCommand(connectionAtomRuntime, { + label: "preview route acquisition", + execute: ({ connection, target }: EnvironmentPortRouteRequest) => + Effect.gen(function* () { + const scope = yield* Scope.make(); + const resolution = yield* EnvironmentPortRouter.pipe( + Effect.flatMap((router) => router.acquire({ connection, target })), + Scope.provide(scope), + Effect.onError(() => Scope.close(scope, Exit.void)), + ); + return { resolution, scope } satisfies AcquiredRoute; + }), +}); -const resolveEnvironmentPortTarget = ( +export async function acquireBrowserNavigationTarget( environmentId: EnvironmentId, - target: Extract, - environmentUrl: URL, - requestedUrl?: string, - sourceUrl?: URL, -): PreviewUrlResolution => { - if (!isPrivateNetworkHost(environmentUrl.hostname)) { - throw new Error( - "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.", - ); + target: BrowserNavigationTarget, +): Promise { + const connection = readPreparedConnection(environmentId); + if (connection === null) { + throw new Error(`Environment ${environmentId} is not connected.`); } - const protocol = target.protocol ?? "http"; - const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`; - const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, ""); - const resolvedHost = normalizedEnvironmentHost.includes(":") - ? `[${normalizedEnvironmentHost}]` - : normalizedEnvironmentHost; - const resolved = sourceUrl - ? new URL(sourceUrl) - : new URL(path, `${protocol}://${resolvedHost}:${target.port}`); - if (sourceUrl) { - resolved.hostname = resolvedHost; - resolved.port = String(target.port); + const result = await acquireRouteCommand.run(appAtomRegistry, { connection, target }); + if (result._tag === "Failure") { + throw squashAtomCommandFailure(result); } + + const acquired = result.value; + let ownership: "pending" | "committed" | "released" = "pending"; return { - requestedUrl: requestedUrl ?? `${protocol}://localhost:${target.port}${path}`, - resolvedUrl: resolved.toString(), - resolutionKind: isLocalLoopbackHost(normalizedEnvironmentHost) - ? "direct" - : "direct-private-network", - environmentId, + resolution: acquired.resolution, + commit: async (tabId) => { + if (ownership !== "pending") return; + ownership = "committed"; + const previous = activeRoutes.get(tabId); + if (acquired.resolution.resolutionKind === "ssh-forward") { + activeRoutes.set(tabId, acquired.scope); + } else { + activeRoutes.delete(tabId); + await closeRouteScope(acquired.scope); + } + if (previous !== undefined && previous !== acquired.scope) { + await closeRouteScope(previous); + } + }, + release: async () => { + if (ownership !== "pending") return; + ownership = "released"; + await closeRouteScope(acquired.scope); + }, }; -}; +} -export function resolveBrowserNavigationTarget( +export async function acquireDiscoveredServerRoute( environmentId: EnvironmentId, - target: BrowserNavigationTarget, -): PreviewUrlResolution { - if (target.kind === "url") { - let parsed: URL | null = null; - try { - parsed = new URL(normalizePreviewUrl(target.url)); - } catch { - // Preserve the existing direct-navigation behavior so the preview host - // reports malformed URL errors through its normal navigation path. - } - if (parsed && isLoopbackHost(parsed.hostname)) { - const environmentUrl = readEnvironmentUrl(environmentId); - if (parsed.hostname === "0.0.0.0" || !isLocalLoopbackHost(environmentUrl.hostname)) { - return resolveEnvironmentPortTarget( - environmentId, - { - kind: "environment-port", - port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)), - protocol: parsed.protocol === "https:" ? "https" : "http", - path: `${parsed.pathname}${parsed.search}${parsed.hash}`, - }, - environmentUrl, - target.url, - parsed, - ); - } - } - return { - requestedUrl: target.url, - resolvedUrl: target.url, - resolutionKind: "direct", - environmentId, - }; + rawUrl: string, +): Promise { + let url = rawUrl; + try { + url = normalizePreviewUrl(rawUrl); + } catch { + // Keep malformed input on the preview's normal navigation error path. } - return resolveEnvironmentPortTarget(environmentId, target, readEnvironmentUrl(environmentId)); + return acquireBrowserNavigationTarget(environmentId, { kind: "url", url }); } -export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { +export async function releaseBrowserNavigationRoute(tabId: string): Promise { + const scope = activeRoutes.get(tabId); + if (scope === undefined) return; + activeRoutes.delete(tabId); + await closeRouteScope(scope); +} + +export async function withBrowserNavigationRoute( + route: BrowserNavigationRoute | undefined, + use: () => Promise, +): Promise { try { - const normalizedUrl = normalizePreviewUrl(rawUrl); - return resolveBrowserNavigationTarget(environmentId, { - kind: "url", - url: normalizedUrl, - }).resolvedUrl; - } catch { - return rawUrl; + return await use(); + } finally { + await route?.release(); } } + +export async function resetBrowserNavigationRoutesForTests(): Promise { + const scopes = [...activeRoutes.values()]; + activeRoutes.clear(); + await Promise.all(scopes.map(closeRouteScope)); +} diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 06954b73f37..80c63d21600 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -28,7 +28,10 @@ import { updatePreviewServerSnapshot, } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; -import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; +import { + acquireBrowserNavigationTarget, + withBrowserNavigationRoute, +} from "~/browser/browserTargetResolver"; import { readActiveBrowserRecordingTabId, startBrowserRecording, @@ -343,81 +346,89 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) return await currentStatus(threadRef, tabId); case "open": { const input = request.input as PreviewAutomationOpenInput; - const resolvedInputUrl = input.url - ? resolveBrowserNavigationTarget(environmentId, { + const route = input.url + ? await acquireBrowserNavigationTarget(environmentId, { kind: "url", url: input.url, - }).resolvedUrl - : undefined; - let activeTabId = resolvePreviewAutomationOpenTab( - state, - request.tabId, - input.reuseExistingTab ?? true, - ); - let activeSnapshot = activeTabId - ? (state.sessions[activeTabId] ?? state.snapshot ?? undefined) + }) : undefined; - const reusedExistingTab = activeTabId !== null; - tabId = activeTabId; - if (!activeTabId) { - const result = await open({ - environmentId, - input: { - threadId: request.threadId, - ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}), - }, - }); - if (result._tag === "Failure") { - return raiseAtomCommandFailure(result); - } - const snapshot = result.value; - applyPreviewServerSnapshot(threadRef, snapshot); - activeTabId = snapshot.tabId; - activeSnapshot = snapshot; - tabId = activeTabId; - } - if (input.show ?? true) { - useRightPanelStore.getState().openBrowser(threadRef, activeTabId); - } - if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) { - await waitForDesktopOverlay( - threadRef, - request.requestId, - activeTabId, - request.timeoutMs, - ); - } - if (reusedExistingTab && resolvedInputUrl && previewBridge) { - await previewBridge.navigate(activeTabId, resolvedInputUrl); - await waitForNavigationReadiness( - threadRef, - request.requestId, - activeTabId, - "load", - request.timeoutMs, + const resolvedInputUrl = route?.resolution.resolvedUrl; + return await withBrowserNavigationRoute(route, async () => { + let activeTabId = resolvePreviewAutomationOpenTab( + state, + request.tabId, + input.reuseExistingTab ?? true, ); - } - return await currentStatus(threadRef, activeTabId); + let activeSnapshot = activeTabId + ? (state.sessions[activeTabId] ?? state.snapshot ?? undefined) + : undefined; + const reusedExistingTab = activeTabId !== null; + tabId = activeTabId; + if (!activeTabId) { + const result = await open({ + environmentId, + input: { + threadId: request.threadId, + ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}), + }, + }); + if (result._tag === "Failure") { + return raiseAtomCommandFailure(result); + } + const snapshot = result.value; + applyPreviewServerSnapshot(threadRef, snapshot); + activeTabId = snapshot.tabId; + activeSnapshot = snapshot; + tabId = activeTabId; + await route?.commit(activeTabId); + } + if (input.show ?? true) { + useRightPanelStore.getState().openBrowser(threadRef, activeTabId); + } + if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) { + await waitForDesktopOverlay( + threadRef, + request.requestId, + activeTabId, + request.timeoutMs, + ); + } + if (reusedExistingTab && resolvedInputUrl && previewBridge) { + await previewBridge.navigate(activeTabId, resolvedInputUrl); + await route?.commit(activeTabId); + await waitForNavigationReadiness( + threadRef, + request.requestId, + activeTabId, + "load", + request.timeoutMs, + ); + } + return await currentStatus(threadRef, activeTabId); + }); } case "navigate": { const ready = await requireReadyTab(); const input = request.input as PreviewAutomationNavigateInput; - const resolution = resolveBrowserNavigationTarget( + const route = await acquireBrowserNavigationTarget( environmentId, input.target ?? { kind: "url", url: input.url!, }, ); - await ready.bridge.navigate(ready.tabId, resolution.resolvedUrl); - await waitForNavigationReadiness( - threadRef, - request.requestId, - ready.tabId, - input.readiness ?? "load", - input.timeoutMs ?? request.timeoutMs, - ); - return await currentStatus(threadRef, ready.tabId); + return await withBrowserNavigationRoute(route, async () => { + await ready.bridge.navigate(ready.tabId, route.resolution.resolvedUrl); + await route.commit(ready.tabId); + await waitForNavigationReadiness( + threadRef, + request.requestId, + ready.tabId, + input.readiness ?? "load", + input.timeoutMs ?? request.timeoutMs, + ); + return await currentStatus(threadRef, ready.tabId); + }); } case "resize": { const ready = await requireReadyTab(); diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index bb1c4d409eb..908ca6c0f8e 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -17,7 +17,10 @@ import { updatePreviewServerSnapshot, useThreadPreviewState, } from "~/previewStateStore"; -import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; +import { + acquireDiscoveredServerRoute, + withBrowserNavigationRoute, +} from "~/browser/browserTargetResolver"; import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -115,19 +118,26 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, const handleSubmitUrl = useCallback( async (next: string) => { try { - const resolvedUrl = resolveDiscoveredServerUrl(threadRef.environmentId, next); - if (tabId && previewBridge) { - // Drive the webview imperatively; `usePreviewBridge` mirrors the - // resolved URL back to the server so other clients stay in sync. - await previewBridge.navigate(tabId, resolvedUrl); - rememberPreviewUrl(threadRef, resolvedUrl); - } else { - await openPreviewSession({ - openPreview: open, - threadRef, - url: resolvedUrl, - }); - } + const route = await acquireDiscoveredServerRoute(threadRef.environmentId, next); + await withBrowserNavigationRoute(route, async () => { + const resolvedUrl = route.resolution.resolvedUrl; + if (tabId && previewBridge) { + // Drive the webview imperatively; `usePreviewBridge` mirrors the + // resolved URL back to the server so other clients stay in sync. + await previewBridge.navigate(tabId, resolvedUrl); + rememberPreviewUrl(threadRef, resolvedUrl); + await route.commit(tabId); + } else { + const result = await openPreviewSession({ + openPreview: open, + threadRef, + url: resolvedUrl, + }); + if (result._tag === "Success") { + await route.commit(result.value.tabId); + } + } + }); } catch { // Server-side `failed` event renders the unreachable view. } diff --git a/apps/web/src/components/preview/closePreviewSession.test.ts b/apps/web/src/components/preview/closePreviewSession.test.ts index d61d2975a27..2c4977a0097 100644 --- a/apps/web/src/components/preview/closePreviewSession.test.ts +++ b/apps/web/src/components/preview/closePreviewSession.test.ts @@ -7,6 +7,10 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +const releaseBrowserNavigationRoute = vi.hoisted(() => vi.fn(async () => undefined)); + +vi.mock("~/browser/browserTargetResolver", () => ({ releaseBrowserNavigationRoute })); + import { applyPreviewServerSnapshot, readThreadPreviewState, @@ -33,7 +37,10 @@ const snapshot: PreviewSessionSnapshot = { updatedAt: "2026-06-18T19:00:00.000Z", }; -beforeEach(resetPreviewStateForTests); +beforeEach(() => { + resetPreviewStateForTests(); + releaseBrowserNavigationRoute.mockClear(); +}); describe("closePreviewSession", () => { it("suppresses stale server snapshots while the close is in flight", async () => { @@ -60,6 +67,7 @@ describe("closePreviewSession", () => { finishClose?.(); await closing; expect(closePreview).toHaveBeenCalledWith({ threadId: "thread-1", tabId: "tab-1" }); + expect(releaseBrowserNavigationRoute).toHaveBeenCalledWith("tab-1"); }); it("restores the last snapshot when the server close fails", async () => { @@ -75,5 +83,6 @@ describe("closePreviewSession", () => { expect(result._tag).toBe("Failure"); expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); expect(readThreadPreviewState(threadRef).sessions).toEqual({ [snapshot.tabId]: snapshot }); + expect(releaseBrowserNavigationRoute).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/components/preview/closePreviewSession.ts b/apps/web/src/components/preview/closePreviewSession.ts index 5073029f6d3..6151d950520 100644 --- a/apps/web/src/components/preview/closePreviewSession.ts +++ b/apps/web/src/components/preview/closePreviewSession.ts @@ -7,6 +7,7 @@ import type { } from "@t3tools/contracts"; import { beginPreviewSessionClose, cancelPreviewSessionClose } from "~/previewStateStore"; +import { releaseBrowserNavigationRoute } from "~/browser/browserTargetResolver"; interface ClosePreviewSessionInput { readonly closePreview: (input: { @@ -32,6 +33,8 @@ export async function closePreviewSession( }); if (result._tag === "Failure") { cancelPreviewSessionClose(input.threadRef, input.snapshot, input.tabId); + } else { + await releaseBrowserNavigationRoute(input.tabId); } return result; } diff --git a/apps/web/src/components/preview/openDiscoveredPort.test.ts b/apps/web/src/components/preview/openDiscoveredPort.test.ts new file mode 100644 index 00000000000..a22d56c8da4 --- /dev/null +++ b/apps/web/src/components/preview/openDiscoveredPort.test.ts @@ -0,0 +1,94 @@ +import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + acquireDiscoveredServerRoute: vi.fn(), + commit: vi.fn(async () => undefined), + release: vi.fn(async () => undefined), + openBrowser: vi.fn(), + openPreviewSession: vi.fn(), +})); + +vi.mock("~/browser/browserTargetResolver", () => ({ + acquireDiscoveredServerRoute: mocks.acquireDiscoveredServerRoute, +})); +vi.mock("~/rightPanelStore", () => ({ + useRightPanelStore: { getState: () => ({ openBrowser: mocks.openBrowser }) }, +})); +vi.mock("./openPreviewSession", () => ({ openPreviewSession: mocks.openPreviewSession })); + +import { openDiscoveredPort } from "./openDiscoveredPort"; + +const threadRef = { + environmentId: "environment-1", + threadId: "thread-1", +} as ScopedThreadRef; + +const snapshot = { + threadId: threadRef.threadId, + tabId: "tab-1", + navStatus: { _tag: "Success", url: "http://127.0.0.1:42173/", title: "App" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-07-16T00:00:00.000Z", +} satisfies PreviewSessionSnapshot; + +describe("openDiscoveredPort", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.acquireDiscoveredServerRoute.mockResolvedValue({ + resolution: { resolvedUrl: "http://127.0.0.1:42173/" }, + commit: mocks.commit, + release: mocks.release, + }); + mocks.openPreviewSession.mockResolvedValue(AsyncResult.success(snapshot)); + }); + + it("holds the route through preview creation and transfers it to the new tab", async () => { + const result = await openDiscoveredPort({ + threadRef, + port: { + host: "localhost", + port: 5173, + url: "http://localhost:5173/", + processName: null, + pid: null, + terminal: null, + }, + openPreview: vi.fn(), + }); + + expect(result._tag).toBe("Success"); + expect(mocks.acquireDiscoveredServerRoute).toHaveBeenCalledWith( + threadRef.environmentId, + "http://localhost:5173/", + ); + expect(mocks.openPreviewSession).toHaveBeenCalledWith( + expect.objectContaining({ url: "http://127.0.0.1:42173/" }), + ); + expect(mocks.commit).toHaveBeenCalledWith("tab-1"); + expect(mocks.release).toHaveBeenCalledOnce(); + expect(mocks.openBrowser).toHaveBeenCalledWith(threadRef, "tab-1"); + }); + + it("reports route acquisition failures as command failures", async () => { + mocks.acquireDiscoveredServerRoute.mockRejectedValueOnce(new Error("forward failed")); + + const result = await openDiscoveredPort({ + threadRef, + port: { + host: "localhost", + port: 5173, + url: "http://localhost:5173/", + processName: null, + pid: null, + terminal: null, + }, + openPreview: vi.fn(), + }); + + expect(result._tag).toBe("Failure"); + expect(mocks.openPreviewSession).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/preview/openDiscoveredPort.ts b/apps/web/src/components/preview/openDiscoveredPort.ts index 664c2e33a5c..9eecb689439 100644 --- a/apps/web/src/components/preview/openDiscoveredPort.ts +++ b/apps/web/src/components/preview/openDiscoveredPort.ts @@ -3,8 +3,10 @@ import { mapAtomCommandResult, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; -import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; +import { acquireDiscoveredServerRoute } from "~/browser/browserTargetResolver"; import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; import { useRightPanelStore } from "~/rightPanelStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -14,13 +16,24 @@ export async function openDiscoveredPort(input: { readonly port: DiscoveredLocalServer; readonly openPreview: OpenPreviewMutation; }): Promise> { - const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); - const result = await openPreviewSession({ - openPreview: input.openPreview, - threadRef: input.threadRef, - url: resolvedUrl, - }); - return mapAtomCommandResult(result, (snapshot) => { - useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); - }); + try { + const route = await acquireDiscoveredServerRoute(input.threadRef.environmentId, input.port.url); + try { + const result = await openPreviewSession({ + openPreview: input.openPreview, + threadRef: input.threadRef, + url: route.resolution.resolvedUrl, + }); + if (result._tag === "Success") { + await route.commit(result.value.tabId); + } + return mapAtomCommandResult(result, (snapshot) => { + useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); + }); + } finally { + await route.release(); + } + } catch (cause) { + return AsyncResult.failure(Cause.die(cause)); + } } diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.ts index 118a56b9068..74202b3c503 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.ts @@ -3,7 +3,6 @@ import { isLoopbackHost } from "@t3tools/shared/preview"; import { useMemo } from "react"; import type { EnvironmentId } from "@t3tools/contracts"; -import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; import { useDiscoveredPorts } from "~/portDiscoveryState"; export interface PreviewableServer extends DiscoveredLocalServer { @@ -33,10 +32,7 @@ export function useDiscoveredLocalServers( return useMemo( () => mergeServers({ - scanner: scannerSnapshot.map((server) => ({ - ...server, - url: resolveDiscoveredServerUrl(input.environmentId, server.url), - })), + scanner: scannerSnapshot, configuredUrls: input.configuredUrls ?? [], recentlySeenUrls: input.recentlySeenUrls ?? [], }), diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 56d25fa142e..70558eb7e23 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -272,6 +272,34 @@ const capabilitiesLayer = Layer.effectContext( }), }); }), + forwardPort: Effect.fn("web.connectionPlatform.ssh.forwardPort")(function* (input) { + const bridge = window.desktopBridge; + if (bridge === undefined) { + return yield* new ConnectionBlockedError({ + reason: "unsupported", + detail: "SSH port forwarding is only available in the desktop app.", + }); + } + const lease = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => bridge.acquireSshPortForward(input.target, input.remotePort), + catch: sshPreparationError, + }), + (acquired) => + Effect.tryPromise(() => bridge.releaseSshPortForward(acquired.leaseId)).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not release SSH preview port forward.").pipe( + Effect.annotateLogs({ + leaseId: acquired.leaseId, + remotePort: acquired.remotePort, + cause, + }), + ), + ), + ), + ); + return { localPort: lease.localPort }; + }), }); return Context.make(CloudSession, cloudSession).pipe( diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d9e19889721..cbfa92a063e 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -35,6 +35,10 @@ "types": "./src/platform/index.ts", "default": "./src/platform/index.ts" }, + "./preview": { + "types": "./src/preview/index.ts", + "default": "./src/preview/index.ts" + }, "./relay": { "types": "./src/relay/index.ts", "default": "./src/relay/index.ts" diff --git a/packages/client-runtime/src/connection/layer.ts b/packages/client-runtime/src/connection/layer.ts index 798ec01e2f0..f8f483cbc3a 100644 --- a/packages/client-runtime/src/connection/layer.ts +++ b/packages/client-runtime/src/connection/layer.ts @@ -10,6 +10,7 @@ import * as PlatformConnectionSource from "../platform/source.ts"; import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; import * as RpcSession from "../rpc/session.ts"; +import { EnvironmentPortRouter } from "../preview/router.ts"; const resolverLayer = ConnectionResolver.layer.pipe( Layer.provide(RemoteEnvironmentAuthorization.layer), @@ -27,6 +28,7 @@ const connectionServicesLayer = Layer.mergeAll( registryLayer, RelayEnvironmentDiscovery.layer, onboardingLayer, + EnvironmentPortRouter.layer, ); const connectionStartupLayer = Layer.effectDiscard( diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index 9bee0dad6fb..4d0212a7614 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -234,6 +234,7 @@ describe("connection onboarding", () => { }), prepare: () => Effect.die("unused"), disconnect: () => Effect.die("unused"), + forwardPort: () => Effect.die("unused"), }), ), ); diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index c75cc2fad4b..7561dd693ef 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -330,6 +330,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( provision: () => Effect.die(new Error("SSH provisioning is not used.")), prepare: () => Effect.die(new Error("SSH preparation is not used.")), disconnect: (target) => Ref.update(disconnectedSshTargets, (current) => [...current, target]), + forwardPort: () => Effect.die(new Error("SSH port forwarding is not used.")), }); const driver = ConnectionDriver.ConnectionDriver.of({ connect: (entry, reportProgress) => diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index d0375e55556..a8e55f77cfa 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -159,6 +159,7 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o bearerToken: "ssh-bearer", })), disconnect: () => Effect.void, + forwardPort: () => Effect.die("unused"), }); const dependencies = Layer.mergeAll( diff --git a/packages/client-runtime/src/platform/capabilities.ts b/packages/client-runtime/src/platform/capabilities.ts index a20b7d404b2..c9dd1b87e0d 100644 --- a/packages/client-runtime/src/platform/capabilities.ts +++ b/packages/client-runtime/src/platform/capabilities.ts @@ -8,6 +8,7 @@ import { import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as Option from "effect/Option"; +import type * as Scope from "effect/Scope"; import type { ConnectionAttemptError } from "../connection/model.ts"; @@ -21,6 +22,10 @@ export interface ProvisionedSshEnvironment extends PreparedSshEnvironment { readonly label: string; } +export interface PreparedSshPortForward { + readonly localPort: number; +} + export class CloudSession extends Context.Service< CloudSession, { @@ -64,5 +69,9 @@ export class SshEnvironmentGateway extends Context.Service< readonly disconnect: ( target: DesktopSshEnvironmentTarget, ) => Effect.Effect; + readonly forwardPort: (input: { + readonly target: DesktopSshEnvironmentTarget; + readonly remotePort: number; + }) => Effect.Effect; } >()("@t3tools/client-runtime/platform/capabilities/SshEnvironmentGateway") {} diff --git a/packages/client-runtime/src/preview/index.ts b/packages/client-runtime/src/preview/index.ts new file mode 100644 index 00000000000..d18015da88d --- /dev/null +++ b/packages/client-runtime/src/preview/index.ts @@ -0,0 +1 @@ +export * from "./router.ts"; diff --git a/packages/client-runtime/src/preview/router.test.ts b/packages/client-runtime/src/preview/router.test.ts new file mode 100644 index 00000000000..998abef3c49 --- /dev/null +++ b/packages/client-runtime/src/preview/router.test.ts @@ -0,0 +1,249 @@ +import { assert, describe, it } from "@effect/vitest"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, + type PreparedConnection, +} from "../connection/model.ts"; +import { SshConnectionProfile } from "../connection/catalog.ts"; +import { ConnectionProfileStore } from "../connection/profileStore.ts"; +import { SshEnvironmentGateway } from "../platform/capabilities.ts"; +import { EnvironmentPortRouter } from "./router.ts"; + +const environmentId = EnvironmentId.make("environment-1"); +const sshTarget = { + alias: "devbox", + hostname: "devbox.example.test", + username: "developer", + port: 22, +} as const; + +const primaryConnection = (httpBaseUrl: string): PreparedConnection => ({ + environmentId, + label: "Environment", + httpBaseUrl, + socketUrl: "ws://example.test", + httpAuthorization: null, + target: new PrimaryConnectionTarget({ + environmentId, + label: "Environment", + httpBaseUrl, + wsBaseUrl: "ws://example.test", + }), +}); + +const sshConnection: PreparedConnection = { + environmentId, + label: "SSH environment", + httpBaseUrl: "http://127.0.0.1:3773", + socketUrl: "ws://127.0.0.1:3773", + httpAuthorization: null, + target: new SshConnectionTarget({ + environmentId, + label: "SSH environment", + connectionId: "ssh:environment-1", + }), +}; + +const sshProfile = new SshConnectionProfile({ + connectionId: "ssh:environment-1", + environmentId, + label: "SSH environment", + target: sshTarget, +}); + +const routerLayer = (options?: { + readonly profile?: SshConnectionProfile; + readonly forwardPort?: SshEnvironmentGateway["Service"]["forwardPort"]; +}) => + EnvironmentPortRouter.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed( + ConnectionProfileStore, + ConnectionProfileStore.of({ + get: () => Effect.succeed(Option.fromUndefinedOr(options?.profile)), + put: () => Effect.void, + remove: () => Effect.void, + }), + ), + Layer.succeed( + SshEnvironmentGateway, + SshEnvironmentGateway.of({ + provision: () => Effect.die("unused"), + prepare: () => Effect.die("unused"), + disconnect: () => Effect.void, + forwardPort: options?.forwardPort ?? (() => Effect.die("unused")), + }), + ), + ), + ), + ); + +describe("EnvironmentPortRouter", () => { + it.effect("maps environment ports onto a private network host", () => + Effect.gen(function* () { + const router = yield* EnvironmentPortRouter; + const resolution = yield* router.acquire({ + connection: primaryConnection("http://192.168.1.25:3773"), + target: { kind: "environment-port", port: 5173, path: "/dashboard" }, + }); + assert.deepStrictEqual(resolution, { + requestedUrl: "http://localhost:5173/dashboard", + resolvedUrl: "http://192.168.1.25:5173/dashboard", + resolutionKind: "direct-private-network", + environmentId, + }); + }).pipe(Effect.provide(routerLayer()), Effect.scoped), + ); + + it.effect("keeps localhost navigation local for a local environment", () => + Effect.gen(function* () { + const router = yield* EnvironmentPortRouter; + const resolution = yield* router.acquire({ + connection: primaryConnection("http://127.0.0.1:3773"), + target: { kind: "url", url: "localhost:3000/app" }, + }); + assert.deepStrictEqual(resolution, { + requestedUrl: "localhost:3000/app", + resolvedUrl: "localhost:3000/app", + resolutionKind: "direct", + environmentId, + }); + }).pipe(Effect.provide(routerLayer()), Effect.scoped), + ); + + it.effect("maps localhost URLs onto remote Tailscale hosts", () => + Effect.gen(function* () { + const router = yield* EnvironmentPortRouter; + const resolution = yield* router.acquire({ + connection: primaryConnection("http://100.65.180.100:3773"), + target: { + kind: "url", + url: "http://localhost:5173/dashboard?mode=test#results", + }, + }); + assert.equal( + resolution.resolvedUrl, + "http://100.65.180.100:5173/dashboard?mode=test#results", + ); + assert.equal(resolution.resolutionKind, "direct-private-network"); + }).pipe(Effect.provide(routerLayer()), Effect.scoped), + ); + + it.effect("preserves credentials when mapping onto private IPv6 hosts", () => + Effect.gen(function* () { + const router = yield* EnvironmentPortRouter; + const resolution = yield* router.acquire({ + connection: primaryConnection("http://[fd7a:115c:a1e0::53]:3773"), + target: { + kind: "url", + url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results", + }, + }); + assert.equal( + resolution.resolvedUrl, + "http://user:p%40ss@[fd7a:115c:a1e0::53]:5173/dashboard?mode=test#results", + ); + }).pipe(Effect.provide(routerLayer()), Effect.scoped), + ); + + it.effect("maps wildcard loopback URLs back to localhost for a local environment", () => + Effect.gen(function* () { + const router = yield* EnvironmentPortRouter; + const resolution = yield* router.acquire({ + connection: primaryConnection("http://localhost:3773"), + target: { kind: "url", url: "http://0.0.0.0:3000/app" }, + }); + assert.equal(resolution.resolvedUrl, "http://localhost:3000/app"); + }).pipe(Effect.provide(routerLayer()), Effect.scoped), + ); + + it.effect("recognizes the full IPv4 loopback range on environment endpoints", () => + Effect.gen(function* () { + const router = yield* EnvironmentPortRouter; + const resolution = yield* router.acquire({ + connection: primaryConnection("http://127.0.0.2:3773"), + target: { kind: "url", url: "http://localhost:3000/app" }, + }); + assert.equal(resolution.resolvedUrl, "http://localhost:3000/app"); + assert.equal(resolution.resolutionKind, "direct"); + }).pipe(Effect.provide(routerLayer()), Effect.scoped), + ); + + it.effect("preserves malformed input for the preview navigation error path", () => + Effect.gen(function* () { + const router = yield* EnvironmentPortRouter; + const resolution = yield* router.acquire({ + connection: primaryConnection("http://127.0.0.1:3773"), + target: { kind: "url", url: " " }, + }); + assert.equal(resolution.resolvedUrl, " "); + }).pipe(Effect.provide(routerLayer()), Effect.scoped), + ); + + it.effect("acquires and releases an SSH forward within the caller scope", () => { + let releases = 0; + const forwardPort: SshEnvironmentGateway["Service"]["forwardPort"] = (input) => + Effect.acquireRelease( + Effect.sync(() => { + assert.deepStrictEqual(input, { target: sshTarget, remotePort: 5173 }); + return { localPort: 42_173 }; + }), + () => Effect.sync(() => releases++), + ); + + return Effect.gen(function* () { + const router = yield* EnvironmentPortRouter; + const resolution = yield* Effect.scoped( + router.acquire({ + connection: sshConnection, + target: { + kind: "url", + url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results", + }, + }), + ); + assert.equal( + resolution.resolvedUrl, + "http://user:p%40ss@127.0.0.1:42173/dashboard?mode=test#results", + ); + assert.equal(resolution.resolutionKind, "ssh-forward"); + assert.equal(releases, 1); + }).pipe(Effect.provide(routerLayer({ profile: sshProfile, forwardPort }))); + }); + + it.effect("reports T3 Connect environment ports as unsupported", () => + Effect.gen(function* () { + const router = yield* EnvironmentPortRouter; + const exit = yield* Effect.exit( + router.acquire({ + connection: { + ...primaryConnection("https://relay.example.test"), + target: new RelayConnectionTarget({ environmentId, label: "Relay" }), + }, + target: { kind: "environment-port", port: 5173 }, + }), + ); + assert(exit._tag === "Failure"); + assert.include(String(exit.cause), "T3 Connect preview forwarding is not supported yet"); + }).pipe(Effect.provide(routerLayer()), Effect.scoped), + ); + + it.effect("leaves ordinary public URLs outside environment routing", () => + Effect.gen(function* () { + const router = yield* EnvironmentPortRouter; + const resolution = yield* router.acquire({ + connection: sshConnection, + target: { kind: "url", url: "https://example.com/app" }, + }); + assert.equal(resolution.resolvedUrl, "https://example.com/app"); + assert.equal(resolution.resolutionKind, "direct"); + }).pipe(Effect.provide(routerLayer()), Effect.scoped), + ); +}); diff --git a/packages/client-runtime/src/preview/router.ts b/packages/client-runtime/src/preview/router.ts new file mode 100644 index 00000000000..00136818d8b --- /dev/null +++ b/packages/client-runtime/src/preview/router.ts @@ -0,0 +1,270 @@ +import { + type BrowserNavigationTarget, + EnvironmentId, + type PreviewUrlResolution, +} from "@t3tools/contracts"; +import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; + +import { SshEnvironmentGateway } from "../platform/capabilities.ts"; +import { ConnectionProfileStore } from "../connection/profileStore.ts"; +import type { ConnectionAttemptError, PreparedConnection } from "../connection/model.ts"; + +export class EnvironmentPortRoutingError extends Schema.TaggedErrorClass()( + "EnvironmentPortRoutingError", + { + environmentId: EnvironmentId, + reason: Schema.Literals(["configuration", "unsupported"]), + detail: Schema.String, + }, +) { + override get message(): string { + return this.detail; + } +} + +export interface EnvironmentPortRouteRequest { + readonly connection: PreparedConnection; + readonly target: BrowserNavigationTarget; +} + +type EnvironmentPortTarget = Extract< + BrowserNavigationTarget, + { readonly kind: "environment-port" } +>; + +const normalizeHostname = (host: string): string => host.toLowerCase().replace(/^\[|\]$/g, ""); + +const parseIpv4Address = (host: string): readonly number[] | null => { + const parts = normalizeHostname(host).split(".").map(Number); + return parts.length === 4 && + parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) + ? parts + : null; +}; + +const isLocalLoopbackHost = (host: string): boolean => { + const normalized = normalizeHostname(host); + if (normalized === "localhost" || normalized === "::1") return true; + return parseIpv4Address(normalized)?.[0] === 127; +}; + +const isPrivateNetworkHost = (host: string): boolean => { + const normalized = normalizeHostname(host); + if (isLocalLoopbackHost(normalized) || normalized.endsWith(".local")) return true; + if (normalized.endsWith(".ts.net")) return true; + const parts = parseIpv4Address(normalized); + if (parts) { + return ( + parts[0] === 10 || + (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) || + (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || + (parts[0] === 192 && parts[1] === 168) || + (parts[0] === 169 && parts[1] === 254) + ); + } + const firstIpv6Token = normalized.split(":", 1)[0] ?? ""; + if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false; + const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16); + return ( + Number.isInteger(firstIpv6Hextet) && + ((firstIpv6Hextet & 0xfe00) === 0xfc00 || (firstIpv6Hextet & 0xffc0) === 0xfe80) + ); +}; + +const resolvedHost = (hostname: string): string => { + const normalized = normalizeHostname(hostname); + return normalized.includes(":") ? `[${normalized}]` : normalized; +}; + +const parsePreviewUrl = (rawUrl: string): URL | null => { + try { + return new URL(normalizePreviewUrl(rawUrl)); + } catch { + return null; + } +}; + +const resolveOnHost = (input: { + readonly environmentId: EnvironmentId; + readonly target: EnvironmentPortTarget; + readonly hostname: string; + readonly resolutionKind: PreviewUrlResolution["resolutionKind"]; + readonly requestedUrl?: string; + readonly sourceUrl?: URL; +}): PreviewUrlResolution => { + const protocol = input.target.protocol ?? "http"; + const path = input.target.path?.startsWith("/") + ? input.target.path + : `/${input.target.path ?? ""}`; + const host = resolvedHost(input.hostname); + const resolved = input.sourceUrl + ? new URL(input.sourceUrl) + : new URL(path, `${protocol}://${host}:${input.target.port}`); + if (input.sourceUrl) { + resolved.hostname = host; + resolved.port = String(input.target.port); + } + return { + requestedUrl: input.requestedUrl ?? `${protocol}://localhost:${input.target.port}${path}`, + resolvedUrl: resolved.toString(), + resolutionKind: input.resolutionKind, + environmentId: input.environmentId, + }; +}; + +export class EnvironmentPortRouter extends Context.Service< + EnvironmentPortRouter, + { + readonly acquire: ( + request: EnvironmentPortRouteRequest, + ) => Effect.Effect< + PreviewUrlResolution, + ConnectionAttemptError | EnvironmentPortRoutingError, + Scope.Scope + >; + } +>()("@t3tools/client-runtime/preview/router/EnvironmentPortRouter") { + static readonly layer = Layer.effect( + EnvironmentPortRouter, + Effect.gen(function* () { + const ssh = yield* SshEnvironmentGateway; + const profiles = yield* ConnectionProfileStore; + + const acquireEnvironmentPort = Effect.fn("EnvironmentPortRouter.acquireEnvironmentPort")( + function* (input: { + readonly connection: PreparedConnection; + readonly target: EnvironmentPortTarget; + readonly requestedUrl?: string; + readonly sourceUrl?: URL; + }) { + const environmentId = input.connection.environmentId; + if (input.connection.target._tag === "RelayConnectionTarget") { + return yield* new EnvironmentPortRoutingError({ + environmentId, + reason: "unsupported", + detail: + "This environment port needs the planned authenticated preview gateway; T3 Connect preview forwarding is not supported yet.", + }); + } + + if (input.connection.target._tag === "SshConnectionTarget") { + const sshTarget = input.connection.target; + const profile = yield* profiles.get(sshTarget.connectionId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new EnvironmentPortRoutingError({ + environmentId, + reason: "configuration", + detail: `SSH connection profile ${sshTarget.connectionId} is unavailable.`, + }), + ), + onSome: Effect.succeed, + }), + ), + ); + if (profile._tag !== "SshConnectionProfile") { + return yield* new EnvironmentPortRoutingError({ + environmentId, + reason: "configuration", + detail: `Connection profile ${sshTarget.connectionId} is not an SSH profile.`, + }); + } + const forward = yield* ssh.forwardPort({ + target: profile.target, + remotePort: input.target.port, + }); + return resolveOnHost({ + ...input, + target: { ...input.target, port: forward.localPort }, + requestedUrl: + input.requestedUrl ?? + `${input.target.protocol ?? "http"}://localhost:${input.target.port}${ + input.target.path?.startsWith("/") + ? input.target.path + : `/${input.target.path ?? ""}` + }`, + environmentId, + hostname: "127.0.0.1", + resolutionKind: "ssh-forward", + }); + } + + const environmentUrl = yield* Effect.try({ + try: () => new URL(input.connection.httpBaseUrl), + catch: () => + new EnvironmentPortRoutingError({ + environmentId, + reason: "configuration", + detail: "The environment connection URL is invalid.", + }), + }); + if (!isPrivateNetworkHost(environmentUrl.hostname)) { + return yield* new EnvironmentPortRoutingError({ + environmentId, + reason: "unsupported", + detail: + "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.", + }); + } + return resolveOnHost({ + ...input, + environmentId, + hostname: environmentUrl.hostname, + resolutionKind: isLocalLoopbackHost(environmentUrl.hostname) + ? "direct" + : "direct-private-network", + }); + }, + ); + + const acquire = Effect.fn("EnvironmentPortRouter.acquire")(function* ( + request: EnvironmentPortRouteRequest, + ) { + const environmentId = request.connection.environmentId; + if (request.target.kind === "url") { + const parsed = parsePreviewUrl(request.target.url); + if (parsed && isLoopbackHost(parsed.hostname)) { + const environmentUrl = new URL(request.connection.httpBaseUrl); + if ( + parsed.hostname === "0.0.0.0" || + request.connection.target._tag === "SshConnectionTarget" || + !isLocalLoopbackHost(environmentUrl.hostname) + ) { + return yield* acquireEnvironmentPort({ + connection: request.connection, + target: { + kind: "environment-port", + port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)), + protocol: parsed.protocol === "https:" ? "https" : "http", + path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + }, + requestedUrl: request.target.url, + sourceUrl: parsed, + }); + } + } + return { + requestedUrl: request.target.url, + resolvedUrl: request.target.url, + resolutionKind: "direct" as const, + environmentId, + }; + } + return yield* acquireEnvironmentPort({ + connection: request.connection, + target: request.target, + }); + }); + + return EnvironmentPortRouter.of({ acquire }); + }), + ); +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 02f98ebee58..37000b8fcf6 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -367,6 +367,32 @@ export const DesktopSshEnvironmentEnsureResultSchema = Schema.Union([ DesktopSshPasswordPromptCancelledResultSchema, ]); +export interface DesktopSshPortForward { + leaseId: string; + localPort: number; + remotePort: number; +} + +export const DesktopSshPortForwardSchema = Schema.Struct({ + leaseId: Schema.String, + localPort: Schema.Number, + remotePort: Schema.Number, +}); + +export const DesktopSshPortForwardAcquireInputSchema = Schema.Struct({ + target: DesktopSshEnvironmentTargetSchema, + remotePort: Schema.Number, +}); + +export const DesktopSshPortForwardAcquireResultSchema = Schema.Union([ + DesktopSshPortForwardSchema, + DesktopSshPasswordPromptCancelledResultSchema, +]); + +export const DesktopSshPortForwardReleaseInputSchema = Schema.Struct({ + leaseId: Schema.String, +}); + export const DesktopSshHttpBaseUrlInputSchema = Schema.Struct({ httpBaseUrl: Schema.String, }); @@ -962,6 +988,11 @@ export interface DesktopBridge { options?: { issuePairingToken?: boolean }, ) => Promise; disconnectSshEnvironment: (target: DesktopSshEnvironmentTarget) => Promise; + acquireSshPortForward: ( + target: DesktopSshEnvironmentTarget, + remotePort: number, + ) => Promise; + releaseSshPortForward: (leaseId: string) => Promise; fetchSshEnvironmentDescriptor: (httpBaseUrl: string) => Promise; bootstrapSshBearerSession: ( httpBaseUrl: string, diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 6431dd0dcfd..0f5b8c7edf3 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -851,7 +851,7 @@ export type PreviewAutomationError = typeof PreviewAutomationError.Type; export const PreviewUrlResolution = Schema.Struct({ requestedUrl: Schema.String, resolvedUrl: Schema.String, - resolutionKind: Schema.Literals(["direct", "direct-private-network"]), + resolutionKind: Schema.Literals(["direct", "direct-private-network", "ssh-forward"]), environmentId: EnvironmentId, }); export type PreviewUrlResolution = typeof PreviewUrlResolution.Type; diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index 93a1649b25b..be706d0f8dd 100644 --- a/packages/shared/src/Net.test.ts +++ b/packages/shared/src/Net.test.ts @@ -71,6 +71,18 @@ it.layer(NetService.layer)("NetService", (it) => { ), ); + it.effect("hasListenerOnHost detects an occupied TCP port", () => + Effect.acquireUseRelease( + openServer("127.0.0.1"), + (server) => + Effect.gen(function* () { + const net = yield* NetService.NetService; + assert.equal(yield* net.hasListenerOnHost(getPort(server), "127.0.0.1"), true); + }), + closeServer, + ), + ); + it.effect("findAvailablePort returns preferred when it is free", () => Effect.gen(function* () { const net = yield* NetService.NetService; diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index d7713a72612..0920408e5eb 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -29,6 +29,11 @@ const closeServer = (server: NodeNet.Server) => { }; export interface NetServiceShape { + /** + * Returns true when a TCP client can connect to {host, port}. + */ + readonly hasListenerOnHost: (port: number, host: string) => Effect.Effect; + /** * Returns true when a TCP server can bind to {host, port}. */ @@ -181,6 +186,7 @@ export const make = () => { }); return { + hasListenerOnHost, canListenOnHost, isPortAvailableOnLoopback, reserveLoopbackPort, diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 7e7a5a54276..335db61eedc 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -78,6 +78,7 @@ const testHttpClient = HttpClient.make((request) => const hangingHttpClient = HttpClient.make(() => Effect.never); const testNetService = NetService.NetService.of({ + hasListenerOnHost: () => Effect.succeed(true), canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), reserveLoopbackPort: () => Effect.succeed(41_773), @@ -392,4 +393,65 @@ describe("ssh tunnel scripts", () => { assert.equal(tunnelKillCount, 1); }).pipe(Effect.provide(layer), Effect.scoped); }); + + it.effect("reference-counts shared preview port forwards", () => { + let tunnelSpawnCount = 0; + let tunnelKillCount = 0; + const forwardArgs: ReadonlyArray[] = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + const args = commandArgs(command); + if (args.includes("-N")) { + tunnelSpawnCount += 1; + forwardArgs.push(args); + return makeRunningProcess(() => { + tunnelKillCount += 1; + }); + } + return makeSuccessfulProcess("\n"); + }), + ); + const layer = Layer.mergeAll( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(HttpClient.HttpClient, testHttpClient), + Layer.succeed(NetService.NetService, testNetService), + SshPasswordPrompt.disabledLayer, + SshEnvironmentManager.layer(), + ); + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + + return Effect.gen(function* () { + const manager = yield* SshEnvironmentManager; + const [first, second] = yield* Effect.all( + [manager.acquirePortForward(target, 5173), manager.acquirePortForward(target, 5173)], + { concurrency: "unbounded" }, + ); + + assert.equal(first.localPort, second.localPort); + assert.notEqual(first.leaseId, second.leaseId); + assert.equal(tunnelSpawnCount, 1); + assert.include(forwardArgs[0] ?? [], "127.0.0.1:41773:127.0.0.1:5173"); + + yield* manager.releasePortForward(first.leaseId); + assert.equal(tunnelKillCount, 0); + + yield* manager.releasePortForward(second.leaseId); + assert.equal(tunnelKillCount, 1); + + const third = yield* manager.acquirePortForward(target, 5173); + assert.equal(tunnelSpawnCount, 2); + + yield* manager.disconnectEnvironment(target); + assert.equal(tunnelKillCount, 2); + + yield* manager.releasePortForward(third.leaseId); + assert.equal(tunnelKillCount, 2); + }).pipe(Effect.provide(layer), Effect.scoped); + }); }); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 016d5e9a854..58a01c11f47 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -11,12 +11,14 @@ import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson"; import { satisfiesSemverRange } from "@t3tools/shared/semver"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; @@ -80,6 +82,21 @@ interface SshTunnelEntry { readonly scope: Scope.Scope; } +interface SshPortForwardEntry extends SshTunnelEntry { + readonly connectionKey: string; + readonly leases: Set; +} + +interface PendingSshPortForward { + readonly deferred: Deferred.Deferred; +} + +export interface SshPortForwardLease { + readonly leaseId: string; + readonly localPort: number; + readonly remotePort: number; +} + type SshEnvironmentEffectContext = | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem @@ -150,6 +167,11 @@ export interface SshEnvironmentManagerShape { readonly disconnectEnvironment: ( target: DesktopSshEnvironmentTarget, ) => Effect.Effect; + readonly acquirePortForward: ( + target: DesktopSshEnvironmentTarget, + remotePort: number, + ) => Effect.Effect; + readonly releasePortForward: (leaseId: string) => Effect.Effect; } const RemoteLaunchResult = Schema.Struct({ @@ -917,6 +939,27 @@ const reserveLocalTunnelPort = Effect.fn("ssh/tunnel.reserveLocalTunnelPort")(fu return yield* net.reserveLoopbackPort(); }); +const waitForTcpForwardReady = Effect.fn("ssh/tunnel.waitForTcpForwardReady")(function* ( + localPort: number, +) { + const net = yield* NetService.NetService; + const retryPolicy = Schedule.spaced(Duration.millis(50)).pipe( + Schedule.take(Math.ceil(SSH_READY_TIMEOUT_MS / 50)), + ); + yield* net.hasListenerOnHost(localPort, "127.0.0.1").pipe( + Effect.flatMap((ready) => + ready + ? Effect.void + : Effect.fail( + new SshReadinessError({ + message: `SSH port forward on 127.0.0.1:${localPort} did not become ready.`, + }), + ), + ), + Effect.retry(retryPolicy), + ); +}); + const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: { readonly key: string; readonly resolvedTarget: DesktopSshEnvironmentTarget; @@ -926,6 +969,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: readonly wsBaseUrl: string; readonly authOptions: SshAuthOptions; readonly remoteServerKind: "external" | "managed" | null; + readonly readiness: "http" | "tcp"; }): Effect.fn.Return< SshTunnelEntry, SshCommandError | SshInvalidTargetError | SshReadinessError, @@ -969,7 +1013,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: "-n", "-N", "-L", - `${input.localPort}:127.0.0.1:${input.remotePort}`, + `127.0.0.1:${input.localPort}:127.0.0.1:${input.remotePort}`, hostSpec, ]; const sshCommand = yield* resolveSshCommand; @@ -1068,74 +1112,87 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: }).pipe(Effect.andThen(Effect.fail(error))); }), ); - yield* Effect.raceFirst( - waitForHttpReady({ - baseUrl: input.httpBaseUrl, - timeoutMs: SSH_READY_TIMEOUT_MS, - }), - exitFailure, - ).pipe( - Effect.tap(() => - Effect.logInfo("ssh.tunnel.ready", { - ...sshTargetLogFields(input.resolvedTarget), - command: tunnelCommand, - pid: child.pid, - localPort: input.localPort, - remotePort: input.remotePort, - httpBaseUrl: input.httpBaseUrl, - }), - ), - Effect.tapError((cause) => - Effect.gen(function* () { - const net = yield* NetService.NetService; - const processRunningExit = yield* Effect.exit(child.isRunning); - const localPortAvailableExit = yield* Effect.exit( - net.canListenOnHost(input.localPort, "127.0.0.1"), - ); - const remoteLogTailExit = yield* Effect.exit( - readRemoteServerLogTail(input.resolvedTarget, input.authOptions), - ); - const processRunning = Exit.isSuccess(processRunningExit) ? processRunningExit.value : null; - const localPortAvailable = Exit.isSuccess(localPortAvailableExit) - ? localPortAvailableExit.value - : null; - const remoteLogTail = Exit.isSuccess(remoteLogTailExit) - ? remoteLogTailExit.value || null - : null; - yield* Effect.logWarning("ssh.tunnel.ready.failed", { + const observeReadiness = ( + readiness: Effect.Effect, + ) => + readiness.pipe( + Effect.tap(() => + Effect.logInfo("ssh.tunnel.ready", { ...sshTargetLogFields(input.resolvedTarget), command: tunnelCommand, pid: child.pid, - processRunning, - ...(Exit.isSuccess(processRunningExit) - ? {} - : { processRunningError: processRunningExit.cause }), localPort: input.localPort, - localPortListening: localPortAvailable === null ? null : !localPortAvailable, remotePort: input.remotePort, httpBaseUrl: input.httpBaseUrl, - ...(Exit.isSuccess(localPortAvailableExit) - ? {} - : { localPortProbeError: localPortAvailableExit.cause }), - ...(remoteLogTail === null ? {} : { remoteLogTail }), - ...(Exit.isSuccess(remoteLogTailExit) - ? {} - : { remoteLogTailError: remoteLogTailExit.cause }), - cause, - }); - }), - ), - Effect.onExit((exit) => - Exit.isSuccess(exit) - ? Effect.void - : child - .kill({ - killSignal: "SIGTERM", - forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS, - }) - .pipe(Effect.ignore), - ), - ); + }), + ), + Effect.tapError((cause) => + Effect.gen(function* () { + const net = yield* NetService.NetService; + const processRunningExit = yield* Effect.exit(child.isRunning); + const localPortAvailableExit = yield* Effect.exit( + net.canListenOnHost(input.localPort, "127.0.0.1"), + ); + const remoteLogTailExit = + input.readiness === "http" + ? yield* Effect.exit(readRemoteServerLogTail(input.resolvedTarget, input.authOptions)) + : Exit.succeed(""); + const processRunning = Exit.isSuccess(processRunningExit) + ? processRunningExit.value + : null; + const localPortAvailable = Exit.isSuccess(localPortAvailableExit) + ? localPortAvailableExit.value + : null; + const remoteLogTail = Exit.isSuccess(remoteLogTailExit) + ? remoteLogTailExit.value || null + : null; + yield* Effect.logWarning("ssh.tunnel.ready.failed", { + ...sshTargetLogFields(input.resolvedTarget), + command: tunnelCommand, + pid: child.pid, + processRunning, + ...(Exit.isSuccess(processRunningExit) + ? {} + : { processRunningError: processRunningExit.cause }), + localPort: input.localPort, + localPortListening: localPortAvailable === null ? null : !localPortAvailable, + remotePort: input.remotePort, + httpBaseUrl: input.httpBaseUrl, + ...(Exit.isSuccess(localPortAvailableExit) + ? {} + : { localPortProbeError: localPortAvailableExit.cause }), + ...(remoteLogTail === null ? {} : { remoteLogTail }), + ...(Exit.isSuccess(remoteLogTailExit) + ? {} + : { remoteLogTailError: remoteLogTailExit.cause }), + cause, + }); + }), + ), + Effect.onExit((exit) => + Exit.isSuccess(exit) + ? Effect.void + : child + .kill({ + killSignal: "SIGTERM", + forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS, + }) + .pipe(Effect.ignore), + ), + ); + if (input.readiness === "http") { + yield* observeReadiness( + Effect.raceFirst( + waitForHttpReady({ + baseUrl: input.httpBaseUrl, + timeoutMs: SSH_READY_TIMEOUT_MS, + }), + exitFailure, + ), + ); + } else { + yield* observeReadiness(Effect.raceFirst(waitForTcpForwardReady(input.localPort), exitFailure)); + } return tunnelEntry; }); @@ -1148,6 +1205,10 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma string, Deferred.Deferred >(); + const portForwards = new Map(); + const pendingPortForwards = new Map(); + const portForwardLeases = new Map(); + let nextPortForwardLeaseId = 0; const authSecrets = new Map(); const closeTunnelEntry = Effect.fn("ssh/tunnel.closeTunnelEntry")(function* ( @@ -1180,11 +1241,60 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma yield* Deferred.fail(pending, makeSshTunnelCancelledError(target)).pipe(Effect.ignore); }); + const closePortForwardEntry = Effect.fn("ssh/tunnel.closePortForwardEntry")(function* ( + entry: SshPortForwardEntry, + ) { + yield* Effect.logDebug("ssh.portForward.close.start", { + ...sshTargetLogFields(entry.target), + key: entry.key, + localPort: entry.localPort, + remotePort: entry.remotePort, + leases: entry.leases.size, + }); + if (portForwards.get(entry.key) === entry) { + portForwards.delete(entry.key); + } + for (const leaseId of entry.leases) { + portForwardLeases.delete(leaseId); + } + entry.leases.clear(); + yield* Scope.close(entry.scope, Exit.void).pipe(Effect.ignore); + yield* Effect.logInfo("ssh.portForward.close.succeeded", { + ...sshTargetLogFields(entry.target), + key: entry.key, + localPort: entry.localPort, + remotePort: entry.remotePort, + }); + }); + + const cancelPendingPortForward = Effect.fn("ssh/tunnel.cancelPendingPortForward")(function* ( + key: string, + target: DesktopSshEnvironmentTarget, + ) { + const pending = pendingPortForwards.get(key); + if (!pending) { + return; + } + pendingPortForwards.delete(key); + yield* Deferred.fail(pending.deferred, makeSshTunnelCancelledError(target)).pipe(Effect.ignore); + }); + yield* Scope.addFinalizer( managerScope, - Effect.sync(() => [...tunnels.values()]).pipe( - Effect.flatMap((entries) => - Effect.forEach(entries, closeTunnelEntry, { concurrency: "unbounded" }), + Effect.sync(() => ({ + tunnels: [...tunnels.values()], + portForwards: [...portForwards.values()], + })).pipe( + Effect.flatMap(({ tunnels: tunnelEntries, portForwards: portForwardEntries }) => + Effect.all( + [ + Effect.forEach(tunnelEntries, closeTunnelEntry, { concurrency: "unbounded" }), + Effect.forEach(portForwardEntries, closePortForwardEntry, { + concurrency: "unbounded", + }), + ], + { concurrency: "unbounded" }, + ), ), Effect.ignore, ), @@ -1301,6 +1411,17 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma }); }); + const resolveManagerTarget = Effect.fn("ssh/tunnel.resolveManagerTarget")(function* ( + target: DesktopSshEnvironmentTarget, + ) { + const baseResolved = yield* resolveSshTarget(target.alias || target.hostname); + return { + ...baseResolved, + ...(target.username !== null ? { username: target.username } : {}), + ...(target.port !== null ? { port: target.port } : {}), + } satisfies DesktopSshEnvironmentTarget; + }); + const createTunnelEntry = Effect.fn("ssh/tunnel.ensureTunnelEntry.create")(function* (input: { readonly key: string; readonly resolvedTarget: DesktopSshEnvironmentTarget; @@ -1347,6 +1468,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma wsBaseUrl, authOptions, remoteServerKind: remoteLaunch.remoteServerKind, + readiness: "http", }).pipe(Effect.provideService(Scope.Scope, entryScope)), }).pipe( Effect.onExit((exit) => @@ -1486,6 +1608,186 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma ); }); + const createPortForwardEntry = Effect.fn("ssh/tunnel.portForward.create")(function* (input: { + readonly key: string; + readonly connectionKey: string; + readonly resolvedTarget: DesktopSshEnvironmentTarget; + readonly remotePort: number; + }): Effect.fn.Return< + SshPortForwardEntry, + SshEnvironmentEffectError, + SshEnvironmentEffectContext + > { + const localPort = yield* reserveLocalTunnelPort(); + const httpBaseUrl = `http://127.0.0.1:${localPort}/`; + const wsBaseUrl = `ws://127.0.0.1:${localPort}/`; + const entryScope = yield* Scope.make("sequential"); + const tunnelEntry = yield* runWithSshAuth({ + key: input.connectionKey, + target: input.resolvedTarget, + operation: (authOptions) => + startSshTunnel({ + key: input.key, + resolvedTarget: input.resolvedTarget, + remotePort: input.remotePort, + localPort, + httpBaseUrl, + wsBaseUrl, + authOptions, + remoteServerKind: null, + readiness: "tcp", + }).pipe(Effect.provideService(Scope.Scope, entryScope)), + }).pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) ? Effect.void : Scope.close(entryScope, Exit.void).pipe(Effect.ignore), + ), + ); + const entry: SshPortForwardEntry = { + ...tunnelEntry, + connectionKey: input.connectionKey, + leases: new Set(), + }; + yield* Scope.addFinalizer( + entryScope, + Effect.gen(function* () { + if (portForwards.get(entry.key) === entry) { + portForwards.delete(entry.key); + } + for (const leaseId of entry.leases) { + portForwardLeases.delete(leaseId); + } + entry.leases.clear(); + yield* entry.process + .kill({ + killSignal: "SIGTERM", + forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS, + }) + .pipe(Effect.ignore); + }), + ); + yield* Effect.logInfo("ssh.portForward.create.succeeded", { + ...sshTargetLogFields(entry.target), + key: entry.key, + localPort: entry.localPort, + remotePort: entry.remotePort, + }); + return entry; + }); + + const ensurePortForwardEntry = Effect.fn("ssh/tunnel.portForward.ensure")(function* (input: { + readonly key: string; + readonly connectionKey: string; + readonly resolvedTarget: DesktopSshEnvironmentTarget; + readonly remotePort: number; + }): Effect.fn.Return< + SshPortForwardEntry, + SshEnvironmentEffectError, + SshEnvironmentEffectContext + > { + const existing = portForwards.get(input.key); + if (existing !== undefined) { + const running = yield* existing.process.isRunning.pipe(Effect.orElseSucceed(() => false)); + if (running) { + return existing; + } + yield* closePortForwardEntry(existing); + } + + const pending = pendingPortForwards.get(input.key); + if (pending !== undefined) { + return yield* Deferred.await(pending.deferred); + } + + const deferred = yield* Deferred.make(); + const pendingEntry: PendingSshPortForward = { + deferred, + }; + pendingPortForwards.set(input.key, pendingEntry); + return yield* createPortForwardEntry(input).pipe( + Effect.flatMap((entry) => { + if (pendingPortForwards.get(input.key) !== pendingEntry) { + return closePortForwardEntry(entry).pipe( + Effect.andThen(Effect.fail(makeSshTunnelCancelledError(input.resolvedTarget))), + ); + } + portForwards.set(input.key, entry); + return Effect.succeed(entry); + }), + Effect.tapError((cause) => + Effect.logWarning("ssh.portForward.create.failed", { + ...sshTargetLogFields(input.resolvedTarget), + key: input.key, + remotePort: input.remotePort, + cause, + }), + ), + Effect.onExit((exit) => + Effect.sync(() => { + if (pendingPortForwards.get(input.key) === pendingEntry) { + pendingPortForwards.delete(input.key); + } + }).pipe(Effect.andThen(Deferred.done(deferred, exit))), + ), + ); + }); + + const acquirePortForward = Effect.fn("ssh/tunnel.acquirePortForward")(function* ( + target: DesktopSshEnvironmentTarget, + remotePort: number, + ): Effect.fn.Return { + if (!Number.isInteger(remotePort) || remotePort < 1 || remotePort > 65_535) { + return yield* new SshInvalidTargetError({ + message: `Invalid remote SSH port: ${remotePort}.`, + }); + } + const resolvedTarget = yield* resolveManagerTarget(target); + const connectionKey = targetConnectionKey(resolvedTarget); + const key = `${connectionKey}\0${remotePort}`; + const entry = yield* ensurePortForwardEntry({ + key, + connectionKey, + resolvedTarget, + remotePort, + }); + nextPortForwardLeaseId += 1; + const leaseId = `ssh-port-forward-${nextPortForwardLeaseId}`; + entry.leases.add(leaseId); + portForwardLeases.set(leaseId, entry); + yield* Effect.logInfo("ssh.portForward.acquired", { + ...sshTargetLogFields(entry.target), + leaseId, + localPort: entry.localPort, + remotePort: entry.remotePort, + references: entry.leases.size, + }); + return { + leaseId, + localPort: entry.localPort, + remotePort: entry.remotePort, + }; + }); + + const releasePortForward = Effect.fn("ssh/tunnel.releasePortForward")(function* ( + leaseId: string, + ) { + const entry = portForwardLeases.get(leaseId); + if (entry === undefined) { + return; + } + portForwardLeases.delete(leaseId); + entry.leases.delete(leaseId); + yield* Effect.logInfo("ssh.portForward.released", { + ...sshTargetLogFields(entry.target), + leaseId, + localPort: entry.localPort, + remotePort: entry.remotePort, + references: entry.leases.size, + }); + if (entry.leases.size === 0) { + yield* closePortForwardEntry(entry); + } + }); + const ensureEnvironment = Effect.fn("ssh/tunnel.ensureEnvironment")(function* ( target: DesktopSshEnvironmentTarget, requestOptions?: { readonly issuePairingToken?: boolean }, @@ -1498,12 +1800,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma ...sshTargetLogFields(target), issuePairingToken: requestOptions?.issuePairingToken === true, }); - const baseResolved = yield* resolveSshTarget(target.alias || target.hostname); - const resolvedTarget: DesktopSshEnvironmentTarget = { - ...baseResolved, - ...(target.username !== null ? { username: target.username } : {}), - ...(target.port !== null ? { port: target.port } : {}), - }; + const resolvedTarget = yield* resolveManagerTarget(target); const key = targetConnectionKey(resolvedTarget); yield* Effect.logDebug("ssh.environment.target.resolved", { ...sshTargetLogFields(resolvedTarget), @@ -1554,20 +1851,32 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma target: DesktopSshEnvironmentTarget, ): Effect.fn.Return { yield* Effect.logInfo("ssh.environment.disconnect.start", sshTargetLogFields(target)); - const baseResolved = yield* resolveSshTarget(target.alias || target.hostname); - const resolvedTarget: DesktopSshEnvironmentTarget = { - ...baseResolved, - ...(target.username !== null ? { username: target.username } : {}), - ...(target.port !== null ? { port: target.port } : {}), - }; + const resolvedTarget = yield* resolveManagerTarget(target); const key = targetConnectionKey(resolvedTarget); + const forwardEntries = [...portForwards.values()].filter( + (entry) => entry.connectionKey === key, + ); + const pendingForwardKeys = [...pendingPortForwards.keys()].filter((forwardKey) => + forwardKey.startsWith(`${key}\0`), + ); const entry = tunnels.get(key) ?? null; yield* Effect.logDebug("ssh.environment.disconnect.targetResolved", { ...sshTargetLogFields(resolvedTarget), key, hasTunnel: entry !== null, hasPendingTunnel: pendingTunnelEntries.has(key), + portForwards: forwardEntries.length, + pendingPortForwards: pendingForwardKeys.length, }); + yield* Effect.forEach(forwardEntries, closePortForwardEntry, { + concurrency: "unbounded", + discard: true, + }); + yield* Effect.forEach( + pendingForwardKeys, + (forwardKey) => cancelPendingPortForward(forwardKey, resolvedTarget), + { concurrency: "unbounded", discard: true }, + ); if (entry !== null) { yield* closeTunnelEntry(entry); } @@ -1585,7 +1894,12 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma }); }); - return SshEnvironmentManager.of({ ensureEnvironment, disconnectEnvironment }); + return SshEnvironmentManager.of({ + ensureEnvironment, + disconnectEnvironment, + acquirePortForward, + releasePortForward, + }); }); /** diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 432b50cf728..0ea4daa36c1 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -24,6 +24,7 @@ import { const emptyConfigLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })); const netServiceLayer = Layer.succeed(NetService.NetService, { + hasListenerOnHost: () => Effect.succeed(false), canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), reserveLoopbackPort: () => Effect.succeed(49_152),