From be25b8ebf794661b394fa7dc019fb18242375dbf Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 13 Aug 2026 15:26:42 -0400 Subject: [PATCH 01/10] fix(preview): open Portless dev server URLs --- apps/server/src/preview/PortScanner.test.ts | 64 +++++++++- apps/server/src/preview/PortScanner.ts | 111 +++++++++++++++++- .../src/browser/browserTargetResolver.test.ts | 24 ++++ apps/web/src/browser/browserTargetResolver.ts | 27 ++++- apps/web/src/components/LegacySidebar.tsx | 16 ++- apps/web/src/components/Sidebar.tsx | 63 ++++++++++ .../preview/PreviewLocalServerCard.tsx | 11 +- .../components/preview/openDiscoveredPort.ts | 6 +- .../preview/useDiscoveredLocalServers.test.ts | 32 ++++- .../preview/useDiscoveredLocalServers.ts | 25 +++- docs/user/thread-sidebar.md | 6 + packages/contracts/src/preview.ts | 4 +- 12 files changed, 368 insertions(+), 21 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 7fa15defeca9..a2cd65864a6c 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -1,5 +1,6 @@ import * as NodeNet from "node:net"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; import { CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS, @@ -18,7 +19,7 @@ import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; import * as TestClock from "effect/testing/TestClock"; -import { expect } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { FetchHttpClient } from "effect/unstable/http"; import * as ProcessRunner from "../processRunner.ts"; @@ -59,6 +60,7 @@ const makeProbeFailureLayer = ( PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( + NodeServices.layer, Layer.succeed(ProcessRunner.ProcessRunner, { run }), Layer.succeed(Net.NetService, { canListenOnHost: () => Effect.succeed(true), @@ -76,6 +78,7 @@ const makeProbeFailureLayer = ( const TestPortDiscoveryLive = PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( + NodeServices.layer, TestProcessRunner, TestIntegrationNet, Layer.succeed(HostProcessPlatform, "win32"), @@ -93,6 +96,7 @@ const makeLsofScannerLayer = (input: { PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( + NodeServices.layer, Layer.succeed(ProcessRunner.ProcessRunner, { run: () => Effect.succeed({ @@ -121,6 +125,64 @@ const makeLsofScannerLayer = (input: { ), ); +describe("Portless route enrichment", () => { + it("uses the live Portless URL for its target listener", () => { + const routes = PortScanner.__testing.parsePortlessRouteSnapshot({ + routesJson: JSON.stringify([ + { + hostname: "eng-1252-simplify-the-onboarding.artelo.localhost", + port: 4058, + pid: 123, + }, + ]), + proxyPortRaw: "443\n", + tls: true, + isProcessAlive: (pid) => pid === 123, + }); + + expect(routes.get(4058)).toBe("https://eng-1252-simplify-the-onboarding.artelo.localhost"); + expect( + PortScanner.__testing.applyPortlessRoutes( + [ + { + host: "localhost", + port: 4058, + url: "http://localhost:4058", + processName: "node", + pid: 456, + terminal: null, + }, + ], + routes, + ), + ).toEqual([ + { + host: "localhost", + port: 4058, + url: "https://eng-1252-simplify-the-onboarding.artelo.localhost", + processName: "node", + pid: 456, + terminal: null, + }, + ]); + }); + + it("ignores stale routes and preserves custom proxy settings", () => { + const routes = PortScanner.__testing.parsePortlessRouteSnapshot({ + routesJson: JSON.stringify([ + { hostname: "stale.localhost", port: 3000, pid: 111 }, + { hostname: "current.test", port: 3001, pid: 222 }, + { hostname: "not a hostname", port: 3002, pid: 222 }, + ]), + proxyPortRaw: "8080", + tls: false, + isProcessAlive: (pid) => pid === 222, + }); + + expect([...routes]).toEqual([[3001, "http://current.test:8080"]]); + }); +}); + const openServer = ( port: number, onConnection: (socket: NodeNet.Socket) => void, diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 4571aeef4c6b..75891bc42f4c 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -16,6 +16,8 @@ * Polling is reference-counted via scoped `retain`. A single layer-scoped fiber * polls forever, but each tick is a no-op when the retain count is zero. */ +import * as NodeOS from "node:os"; + import { CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS, PREVIEW_URL_MAX_LENGTH, @@ -30,10 +32,13 @@ import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import { FetchHttpClient, HttpClient } from "effect/unstable/http"; @@ -78,6 +83,60 @@ const WEB_PROBE_CACHE_TTL_MS = Duration.toMillis(Duration.seconds(15)); const WEB_PROBE_CONCURRENCY = 16; const NAVIGATION_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const PortlessRoute = Schema.Struct({ + hostname: Schema.String, + port: Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThan(65536)), + pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), +}); +const isPortlessRoute = Schema.is(PortlessRoute); +const decodePortlessRouteEntries = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Array(Schema.Unknown)), +); +const PORTLESS_HOSTNAME_PATTERN = + /^(?=.{1,253}$)(?:[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?\.)*[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/i; + +interface PortlessRouteSnapshot { + readonly routesJson: string; + readonly proxyPortRaw: string | null; + readonly tls: boolean; + readonly isProcessAlive: (pid: number) => boolean; +} + +const parsePortlessRouteSnapshot = (input: PortlessRouteSnapshot): ReadonlyMap => { + const decoded = decodePortlessRouteEntries(input.routesJson); + if (Option.isNone(decoded)) return new Map(); + + const defaultProxyPort = input.tls ? 443 : 80; + const parsedProxyPort = Number.parseInt(input.proxyPortRaw?.trim() ?? "", 10); + const proxyPort = + Number.isInteger(parsedProxyPort) && parsedProxyPort > 0 && parsedProxyPort < 65536 + ? parsedProxyPort + : defaultProxyPort; + const protocol = input.tls ? "https" : "http"; + const urlsByTargetPort = new Map(); + + for (const entry of decoded.value) { + if (!isPortlessRoute(entry)) continue; + if (entry.pid !== 0 && !input.isProcessAlive(entry.pid)) continue; + if (!PORTLESS_HOSTNAME_PATTERN.test(entry.hostname)) continue; + if (urlsByTargetPort.has(entry.port)) continue; + + const portSuffix = proxyPort === defaultProxyPort ? "" : `:${proxyPort}`; + urlsByTargetPort.set(entry.port, `${protocol}://${entry.hostname}${portSuffix}`); + } + + return urlsByTargetPort; +}; + +const applyPortlessRoutes = ( + servers: ReadonlyArray, + urlsByTargetPort: ReadonlyMap, +): ReadonlyArray => + servers.map((server) => { + const portlessUrl = urlsByTargetPort.get(server.port); + return portlessUrl === undefined ? server : { ...server, url: portlessUrl }; + }); + type Listener = (servers: ReadonlyArray) => Effect.Effect; interface ListenerSubscription { @@ -294,6 +353,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const processRunner = yield* ProcessRunner.ProcessRunner; const hostPlatform = yield* HostProcessPlatform; const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const stateRef = yield* Ref.make({ listeners: new Map(), terminalProcesses: new Map(), @@ -302,6 +363,36 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const webProbeCacheRef = yield* Ref.make>(new Map()); const scanSemaphore = yield* Semaphore.make(1); + const readPortlessRoutes = Effect.fn("PortDiscovery.readPortlessRoutes")(function* () { + const configuredStateDir = process.env.PORTLESS_STATE_DIR?.trim(); + const stateDir = configuredStateDir || path.join(NodeOS.homedir(), ".portless"); + const routesJson = yield* fileSystem + .readFileString(path.join(stateDir, "routes.json")) + .pipe(Effect.option); + if (Option.isNone(routesJson)) return new Map(); + + const proxyPortRaw = yield* fileSystem + .readFileString(path.join(stateDir, "proxy.port")) + .pipe(Effect.option); + const tls = yield* fileSystem + .exists(path.join(stateDir, "proxy.tls")) + .pipe(Effect.orElseSucceed(() => false)); + + return parsePortlessRouteSnapshot({ + routesJson: routesJson.value, + proxyPortRaw: Option.getOrNull(proxyPortRaw), + tls, + isProcessAlive: (pid) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }, + }); + }); + const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { const results = yield* Effect.forEach( COMMON_DEV_PORTS, @@ -481,6 +572,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { configuredUrls: ReadonlyArray, ) { const state = yield* Ref.get(stateRef); + const portlessRoutes = yield* readPortlessRoutes(); const terminalByProcessId = new Map(); for (const registration of state.terminalProcesses.values()) { for (const processId of registration.processIds) { @@ -509,8 +601,11 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ProcessTimeoutError: recoverWindowsProbeFailure, }), ); - if (listeners !== null) return yield* probeWebServers(listeners, configuredUrls); - return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); + const snapshot = yield* probeWebServers( + listeners ?? (yield* probeCommonPorts()), + configuredUrls, + ); + return { ...snapshot, discovered: applyPortlessRoutes(snapshot.discovered, portlessRoutes) }; } const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); const lsofResult = yield* processRunner @@ -531,8 +626,11 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ProcessTimeoutError: recoverLsofProbeFailure, }), ); - if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls); - return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); + const snapshot = yield* probeWebServers( + lsofResult ?? (yield* probeCommonPorts()), + configuredUrls, + ); + return { ...snapshot, discovered: applyPortlessRoutes(snapshot.discovered, portlessRoutes) }; }); const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")( @@ -662,3 +760,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }).pipe(Effect.withSpan("PortDiscovery.make")); export const layer = Layer.effect(PortDiscovery, make); + +export const __testing = { + applyPortlessRoutes, + parsePortlessRouteSnapshot, +}; diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index cbce157f9a05..1abd269ff512 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -144,6 +144,30 @@ describe("browser target resolver", () => { ).toBe("http://localhost:5173/app?x=1#top"); }); + it("opens a Portless URL directly for a local environment", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect( + resolveDiscoveredServerUrl( + EnvironmentId.make("environment-1"), + "https://feature.artelo.localhost/dashboard", + 4058, + ), + ).toBe("https://feature.artelo.localhost/dashboard"); + }); + + it("preserves the target-port fallback for a remote Portless server", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect( + resolveDiscoveredServerUrl( + EnvironmentId.make("environment-1"), + "https://feature.artelo.localhost/dashboard?mode=test#results", + 4058, + ), + ).toBe("http://100.65.180.100:4058/dashboard?mode=test#results"); + }); + 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( diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 684247e28022..d7bcc089094d 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -241,9 +241,34 @@ export function resolveBrowserNavigationTarget( return resolveEnvironmentPortTarget(environmentId, target, readEnvironmentUrl(environmentId)); } -export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { +export function resolveDiscoveredServerUrl( + environmentId: EnvironmentId, + rawUrl: string, + targetPort?: number, +): string { try { const normalizedUrl = normalizePreviewUrl(rawUrl); + const parsed = new URL(normalizedUrl); + if ( + targetPort !== undefined && + parsed.hostname !== "localhost" && + parsed.hostname.endsWith(".localhost") + ) { + const environmentUrl = readEnvironmentUrl(environmentId); + if (!isLocalLoopbackHost(environmentUrl.hostname)) { + return resolveEnvironmentPortTarget( + environmentId, + { + kind: "environment-port", + port: targetPort, + protocol: "http", + path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + }, + environmentUrl, + normalizedUrl, + ).resolvedUrl; + } + } return resolveBrowserNavigationTarget(environmentId, { kind: "url", url: normalizedUrl, diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 6d6e26c18e35..1f776a42f329 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -90,6 +90,10 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useThreadDiscoveredPorts } from "../portDiscoveryState"; import { openDiscoveredPort } from "./preview/openDiscoveredPort"; +import { + formatDiscoveredServerHost, + selectPreferredDiscoveredServer, +} from "./preview/useDiscoveredLocalServers"; import { useAtomCommand } from "../state/use-atom-command"; import { previewEnvironment } from "../state/preview"; import { @@ -390,6 +394,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); + const preferredDiscoveredPort = selectPreferredDiscoveredServer(discoveredPorts); const environment = useEnvironment(thread.environmentId); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = @@ -427,7 +432,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const isHighlighted = isActive || isSelected; const handleOpenDiscoveredPort = useCallback( (event: React.MouseEvent) => { - const port = discoveredPorts[0]; + const port = preferredDiscoveredPort; if (!port) return; event.preventDefault(); event.stopPropagation(); @@ -448,7 +453,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ); })(); }, - [discoveredPorts, navigateToThread, openPreview, threadRef], + [navigateToThread, openPreview, preferredDiscoveredPort, threadRef], ); const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; @@ -754,13 +759,13 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr )}
- {discoveredPorts.length > 0 && ( + {preferredDiscoveredPort && ( @@ -769,8 +774,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr - Open localhost:{discoveredPorts[0]?.port} - {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""} + Open {formatDiscoveredServerHost(preferredDiscoveredPort)} )} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 63681a54813a..e9f5433e4b16 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -44,6 +44,7 @@ import { FolderIcon, FolderPlusIcon, GitBranchIcon, + Globe2Icon, MessageSquareIcon, PinIcon, PlusIcon, @@ -174,6 +175,13 @@ import { type ProviderInstanceEntry, } from "../providerInstances"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { useThreadDiscoveredPorts } from "../portDiscoveryState"; +import { previewEnvironment } from "../state/preview"; +import { openDiscoveredPort } from "./preview/openDiscoveredPort"; +import { + formatDiscoveredServerHost, + selectPreferredDiscoveredServer, +} from "./preview/useDiscoveredLocalServers"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; @@ -785,6 +793,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { environmentId: thread.environmentId, threadId: thread.id, }); + const discoveredPorts = useThreadDiscoveredPorts({ + environmentId: thread.environmentId, + threadId: thread.id, + }); + const preferredDiscoveredPort = selectPreferredDiscoveredServer(discoveredPorts); + const openPreview = useAtomCommand(previewEnvironment.open, { + reportFailure: false, + }); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const terminalProcessCount = runningTerminalIds.length; @@ -1104,6 +1120,32 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }, [onThreadActivate, openPrLink, openPullRequestsInRightPanel, pr, props.isActive, threadRef], ); + const handleOpenDiscoveredPort = useCallback( + (event: ReactMouseEvent) => { + if (!preferredDiscoveredPort) return; + event.preventDefault(); + event.stopPropagation(); + onThreadActivate(threadRef); + void (async () => { + const result = await openDiscoveredPort({ + threadRef, + port: preferredDiscoveredPort, + openPreview, + }); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open preview", + description: + error instanceof Error ? error.message : "The preview could not be opened.", + }), + ); + })(); + }, + [onThreadActivate, openPreview, preferredDiscoveredPort, threadRef], + ); // All sidebar rows share one surface model. Live threads used to look // like elevated cards while settled threads were plain rows, leaving neither @@ -1228,6 +1270,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { /> ) ) : null; + const discoveredPortButton = preferredDiscoveredPort ? ( + + + } + > + + + + Open {formatDiscoveredServerHost(preferredDiscoveredPort)} + + + ) : null; if (variant === "slim") { return ( @@ -1270,6 +1331,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {title} {pinIndicator} + {discoveredPortButton} {terminalStatusIcon} {isRegeneratingTitle ? ( @@ -1560,6 +1622,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : ( )} + {discoveredPortButton} {terminalStatusIcon} {prBadge} {diff ? ( diff --git a/apps/web/src/components/preview/PreviewLocalServerCard.tsx b/apps/web/src/components/preview/PreviewLocalServerCard.tsx index 263cdb294f48..ea47b17cbf41 100644 --- a/apps/web/src/components/preview/PreviewLocalServerCard.tsx +++ b/apps/web/src/components/preview/PreviewLocalServerCard.tsx @@ -1,7 +1,10 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; import { PreviewFaviconIcon } from "./PreviewFaviconIcon"; -import type { PreviewableServer } from "./useDiscoveredLocalServers"; +import { + formatDiscoveredServerHost, + type PreviewableServer, +} from "./useDiscoveredLocalServers"; interface Props { threadRef: ScopedThreadRef; @@ -20,9 +23,9 @@ export function PreviewLocalServerCard({ threadRef, server, onOpen }: Props) {
{subtitle} - - {server.host}:{server.port} - + + {formatDiscoveredServerHost(server)} +
); diff --git a/apps/web/src/components/preview/openDiscoveredPort.ts b/apps/web/src/components/preview/openDiscoveredPort.ts index a49acbd86104..71a0072b240d 100644 --- a/apps/web/src/components/preview/openDiscoveredPort.ts +++ b/apps/web/src/components/preview/openDiscoveredPort.ts @@ -15,7 +15,11 @@ export async function openDiscoveredPort(input: { readonly port: DiscoveredLocalServer; readonly openPreview: OpenPreviewMutation; }): Promise> { - const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); + const resolvedUrl = resolveDiscoveredServerUrl( + input.threadRef.environmentId, + input.port.url, + input.port.port, + ); const result = await openPreviewSession({ openPreview: input.openPreview, threadRef: input.threadRef, diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts index ba1846902324..7605e5246b4e 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts @@ -1,7 +1,11 @@ import type { DiscoveredLocalServer } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { mergeServers } from "./useDiscoveredLocalServers"; +import { + formatDiscoveredServerHost, + mergeServers, + selectPreferredDiscoveredServer, +} from "./useDiscoveredLocalServers"; const scannerServer = ( overrides: Partial, @@ -144,3 +148,29 @@ describe("mergeServers", () => { expect(result[0]?.requestedUrl).toBe("http://localhost:5173/"); }); }); + +describe("formatDiscoveredServerHost", () => { + it("shows a named proxy instead of its underlying listener", () => { + expect( + formatDiscoveredServerHost(scannerServer({ url: "https://feature-branch.artelo.localhost" })), + ).toBe("feature-branch.artelo.localhost"); + }); + + it("falls back to the listener host and port for an invalid URL", () => { + expect(formatDiscoveredServerHost(scannerServer({ url: "not a url" }))).toBe("localhost:5173"); + }); +}); + +describe("selectPreferredDiscoveredServer", () => { + it("prefers a named proxy over another listener from the same terminal", () => { + const portless = scannerServer({ port: 4314, url: "https://artelo.localhost" }); + expect(selectPreferredDiscoveredServer([scannerServer({ port: 4004 }), portless])).toBe( + portless, + ); + }); + + it("falls back to the first listener when none has a named URL", () => { + const first = scannerServer({ port: 3000, url: "http://localhost:3000" }); + expect(selectPreferredDiscoveredServer([first, scannerServer({ port: 5173 })])).toBe(first); + }); +}); diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.ts index c2907a5b6a6d..ffb40d2bc474 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.ts @@ -34,7 +34,7 @@ export function useDiscoveredLocalServers( mergeServers({ scanner: scannerState.servers.map((server) => ({ ...server, - url: resolveDiscoveredServerUrl(input.environmentId, server.url), + url: resolveDiscoveredServerUrl(input.environmentId, server.url, server.port), requestedUrl: server.url, })), configuredUrls: input.configuredUrls ?? [], @@ -87,6 +87,29 @@ function canonicalKey(host: string, port: number): string { return `${isLoopbackHost(normalizedHost) ? "loopback" : normalizedHost}:${port}`; } +export function formatDiscoveredServerHost( + server: Pick, +): string { + try { + return new URL(server.url).host; + } catch { + return `${server.host}:${server.port}`; + } +} + +export function selectPreferredDiscoveredServer( + servers: ReadonlyArray, +): DiscoveredLocalServer | null { + const namedServer = servers.find((server) => { + try { + return new URL(server.url).hostname !== server.host; + } catch { + return false; + } + }); + return namedServer ?? servers[0] ?? null; +} + function parseLocalUrl(raw: string): { host: string; port: number; url: string } | null { try { const parsed = new URL(raw); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 38c1df18044e..1d6518dff7aa 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -29,3 +29,9 @@ pill** fallback because their colors are not controlled by T3 Code. To generate a fresh title from the conversation, open a thread's context menu and choose **Regenerate title**. While T3 Code is generating it, the action reads **Regenerating…** and cannot be selected again. The option is hidden when the connected environment needs a server update. + +## Dev server shortcuts + +When a terminal starts a local dev server, a globe appears on its thread. Select it to open the +server in T3 Code's browser. T3 Code recognizes Portless routes and opens their stable named URL +instead of the underlying `localhost` port. diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index a1b743afc673..5a6eb8eb0c41 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -289,8 +289,8 @@ export const PreviewEvent = Schema.Union([ export type PreviewEvent = typeof PreviewEvent.Type; /** - * A localhost server detected by the port scanner. Used to populate the - * "Local" recommendations in the empty-state of the preview panel. + * A local server detected by the port scanner. `url` may be a named local + * proxy URL while `host` and `port` identify the underlying listener. */ export const DiscoveredLocalServer = Schema.Struct({ host: TrimmedNonEmptyString, From 90c7a2eb15d3d20a975e4025b6cc662eac704e2b Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 13 Aug 2026 15:41:34 -0400 Subject: [PATCH 02/10] fix(preview): validate Portless host state --- apps/server/src/preview/PortScanner.test.ts | 11 +++++++++++ apps/server/src/preview/PortScanner.ts | 13 +++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index a2cd65864a6c..3476d6ecabba 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -181,6 +181,17 @@ describe("Portless route enrichment", () => { expect([...routes]).toEqual([[3001, "http://current.test:8080"]]); }); + + it.each(["8080abc", "443.5"])("ignores a malformed proxy port of %s", (proxyPortRaw) => { + const routes = PortScanner.__testing.parsePortlessRouteSnapshot({ + routesJson: JSON.stringify([{ hostname: "current.test", port: 3001, pid: 222 }]), + proxyPortRaw, + tls: true, + isProcessAlive: (pid) => pid === 222, + }); + + expect([...routes]).toEqual([[3001, "https://current.test"]]); + }); }); const openServer = ( diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 75891bc42f4c..67b5c817924b 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -16,15 +16,13 @@ * Polling is reference-counted via scoped `retain`. A single layer-scoped fiber * polls forever, but each tick is a no-op when the retain count is zero. */ -import * as NodeOS from "node:os"; - import { CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS, PREVIEW_URL_MAX_LENGTH, ThreadId, type DiscoveredLocalServer, } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Net from "@t3tools/shared/Net"; import { isLoopbackHost, LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; import * as Cause from "effect/Cause"; @@ -107,7 +105,7 @@ const parsePortlessRouteSnapshot = (input: PortlessRouteSnapshot): ReadonlyMap 0 && parsedProxyPort < 65536 ? parsedProxyPort @@ -351,6 +349,7 @@ const serversEqual = ( export const make = Effect.gen(function* PortDiscoveryMake() { const net = yield* Net.NetService; const processRunner = yield* ProcessRunner.ProcessRunner; + const hostEnvironment = yield* HostProcessEnvironment; const hostPlatform = yield* HostProcessPlatform; const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); const fileSystem = yield* FileSystem.FileSystem; @@ -364,8 +363,10 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const scanSemaphore = yield* Semaphore.make(1); const readPortlessRoutes = Effect.fn("PortDiscovery.readPortlessRoutes")(function* () { - const configuredStateDir = process.env.PORTLESS_STATE_DIR?.trim(); - const stateDir = configuredStateDir || path.join(NodeOS.homedir(), ".portless"); + const configuredStateDir = hostEnvironment.PORTLESS_STATE_DIR?.trim(); + const homeDirectory = hostEnvironment.HOME?.trim() || hostEnvironment.USERPROFILE?.trim(); + const stateDir = configuredStateDir || (homeDirectory && path.join(homeDirectory, ".portless")); + if (!stateDir) return new Map(); const routesJson = yield* fileSystem .readFileString(path.join(stateDir, "routes.json")) .pipe(Effect.option); From e243bdbfb1514d194fa0759cc59f9ce4a15eeb4e Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 13 Aug 2026 15:54:01 -0400 Subject: [PATCH 03/10] test(preview): preserve discovered server helpers --- apps/web/src/components/preview/PreviewEmptyState.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/preview/PreviewEmptyState.test.tsx b/apps/web/src/components/preview/PreviewEmptyState.test.tsx index 0f9da0fdb3bd..c89146d61134 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.test.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.test.tsx @@ -15,7 +15,8 @@ const mocks = vi.hoisted(() => ({ }>, })); -vi.mock("./useDiscoveredLocalServers", () => ({ +vi.mock("./useDiscoveredLocalServers", async (importOriginal) => ({ + ...(await importOriginal()), useDiscoveredLocalServers: () => mocks.servers, })); vi.mock("./PreviewFaviconIcon", () => ({ From 3df2a197c3d6e0694cf2f175bf007a64a8675cff Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 13 Aug 2026 16:08:37 -0400 Subject: [PATCH 04/10] fix(preview): preserve remote Portless targets --- .../components/preview/PreviewEmptyState.tsx | 9 ++++--- .../src/components/preview/PreviewView.tsx | 4 +-- .../preview/previewEmptyStateLogic.test.ts | 26 ++++++++++++++++++- .../preview/previewEmptyStateLogic.ts | 9 +++++++ 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/preview/PreviewEmptyState.tsx b/apps/web/src/components/preview/PreviewEmptyState.tsx index 163849154000..1498df4890c6 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.tsx @@ -6,6 +6,7 @@ import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui import { PreviewLocalServerCard } from "./PreviewLocalServerCard"; import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard"; +import { findDiscoveredServerTargetPort } from "./previewEmptyStateLogic"; import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers"; interface Props { @@ -14,7 +15,7 @@ interface Props { configuredUrls?: ReadonlyArray | undefined; recentEntries: ReadonlyArray; onRemoveRecent: (url: string) => void; - onOpenUrl: (url: string) => void; + onOpenUrl: (url: string, targetPort?: number) => void; } export function PreviewEmptyState({ @@ -61,7 +62,9 @@ export function PreviewEmptyState({ key={entry.url} threadRef={threadRef} entry={entry} - onOpen={() => onOpenUrl(entry.url)} + onOpen={() => + onOpenUrl(entry.url, findDiscoveredServerTargetPort(entry.url, servers)) + } onRemove={() => onRemoveRecent(entry.url)} /> ))} @@ -80,7 +83,7 @@ export function PreviewEmptyState({ key={`${server.host}:${server.port}`} threadRef={threadRef} server={server} - onOpen={() => onOpenUrl(server.requestedUrl)} + onOpen={() => onOpenUrl(server.requestedUrl, server.port)} /> ))}
diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 9a812923717a..c007c0856af9 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -187,9 +187,9 @@ export function PreviewView({ ); const handleOpenServerUrl = useCallback( - async (next: string) => { + async (next: string, targetPort?: number) => { try { - const resolved = resolveDiscoveredServerUrl(threadRef.environmentId, next); + const resolved = resolveDiscoveredServerUrl(threadRef.environmentId, next, targetPort); if (await navigateToResolvedUrl(resolved)) { recordVisitForThread(threadRef, next); } diff --git a/apps/web/src/components/preview/previewEmptyStateLogic.test.ts b/apps/web/src/components/preview/previewEmptyStateLogic.test.ts index 3759173d3cc2..e4ace2a7c5b1 100644 --- a/apps/web/src/components/preview/previewEmptyStateLogic.test.ts +++ b/apps/web/src/components/preview/previewEmptyStateLogic.test.ts @@ -1,7 +1,12 @@ import type { PreviewSessionSnapshot, ProjectScript } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { getConfiguredPreviewUrls, shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; +import { + findDiscoveredServerTargetPort, + getConfiguredPreviewUrls, + shouldShowPreviewEmptyState, +} from "./previewEmptyStateLogic"; +import type { PreviewableServer } from "./useDiscoveredLocalServers"; const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessionSnapshot => ({ threadId: "thread-1", @@ -40,3 +45,22 @@ describe("getConfiguredPreviewUrls", () => { ]); }); }); + +describe("findDiscoveredServerTargetPort", () => { + it("finds the listener port for a Portless history URL", () => { + const server = { + host: "localhost", + port: 4058, + url: "http://100.65.180.100:4058/", + requestedUrl: "https://artelo.localhost/", + processName: "node", + pid: 123, + terminal: null, + source: "scanner", + listening: true, + } satisfies PreviewableServer; + + expect(findDiscoveredServerTargetPort("https://artelo.localhost/", [server])).toBe(4058); + expect(findDiscoveredServerTargetPort("https://other.localhost/", [server])).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/preview/previewEmptyStateLogic.ts b/apps/web/src/components/preview/previewEmptyStateLogic.ts index 1ebd074032b0..93c78dfc7ede 100644 --- a/apps/web/src/components/preview/previewEmptyStateLogic.ts +++ b/apps/web/src/components/preview/previewEmptyStateLogic.ts @@ -1,5 +1,7 @@ import type { PreviewSessionSnapshot, ProjectScript } from "@t3tools/contracts"; +import type { PreviewableServer } from "./useDiscoveredLocalServers"; + export function shouldShowPreviewEmptyState(snapshot: PreviewSessionSnapshot | null): boolean { return snapshot === null || snapshot.navStatus._tag === "Idle"; } @@ -9,3 +11,10 @@ export function getConfiguredPreviewUrls( ): ReadonlyArray { return scripts?.flatMap((script) => (script.previewUrl ? [script.previewUrl] : [])) ?? []; } + +export function findDiscoveredServerTargetPort( + url: string, + servers: ReadonlyArray, +): number | undefined { + return servers.find((server) => server.requestedUrl === url)?.port; +} From c7afedcf9bfe1c42dd525d38a7b7bccc1ba9e44f Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 13 Aug 2026 16:14:50 -0400 Subject: [PATCH 05/10] fix(preview): forward Portless listener ports --- .../src/components/preview/PreviewView.test.tsx | 16 ++++++++++++++-- apps/web/src/components/preview/PreviewView.tsx | 2 +- .../preview/previewEmptyStateLogic.test.ts | 2 +- .../components/preview/previewEmptyStateLogic.ts | 6 +++++- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 2ace29ae5c41..ec060913e37f 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -14,7 +14,7 @@ const mocks = vi.hoisted(() => ({ rememberPreviewUrl: vi.fn(), readPreparedConnection: vi.fn(() => ({ httpBaseUrl: "http://172.25.85.75:3773" })), submittedUrl: null as ((url: string) => void) | null, - emptyStateUrl: null as ((url: string) => void) | null, + emptyStateUrl: null as ((url: string, targetPort?: number) => void) | null, togglePictureInPicture: null as (() => void) | null, toggleNativePictureInPicture: null as (() => void) | null, pictureInPicturePressed: false, @@ -231,7 +231,7 @@ vi.mock("./PreviewChromeRow", () => ({ })); vi.mock("./PreviewEmptyState", () => ({ - PreviewEmptyState: (props: { onOpenUrl: (url: string) => void }) => { + PreviewEmptyState: (props: { onOpenUrl: (url: string, targetPort?: number) => void }) => { mocks.emptyStateUrl = props.onOpenUrl; return null; }, @@ -465,6 +465,18 @@ describe("PreviewView navigation", () => { ); }); + it("maps an empty-state Portless server onto its remote listener port", async () => { + mocks.showEmptyState = true; + renderToStaticMarkup(); + + expect(mocks.emptyStateUrl).not.toBeNull(); + mocks.emptyStateUrl?.("https://artelo.localhost/", 4058); + + await vi.waitFor(() => + expect(mocks.navigate).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID, "http://172.25.85.75:4058/"), + ); + }); + it("opens and closes a thread-scoped floating preview for the active tab", async () => { const props = { threadRef: { diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index c007c0856af9..df35c950a155 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -715,7 +715,7 @@ export function PreviewView({ configuredUrls={configuredUrls} recentEntries={recentHistoryEntries} onRemoveRecent={(url) => removeUrlForThread(threadRef, url)} - onOpenUrl={(next) => void handleOpenServerUrl(next)} + onOpenUrl={(next, targetPort) => void handleOpenServerUrl(next, targetPort)} /> ) : null} {snapshot && desktopOverlay ? ( diff --git a/apps/web/src/components/preview/previewEmptyStateLogic.test.ts b/apps/web/src/components/preview/previewEmptyStateLogic.test.ts index e4ace2a7c5b1..ce4cb4b05b74 100644 --- a/apps/web/src/components/preview/previewEmptyStateLogic.test.ts +++ b/apps/web/src/components/preview/previewEmptyStateLogic.test.ts @@ -52,7 +52,7 @@ describe("findDiscoveredServerTargetPort", () => { host: "localhost", port: 4058, url: "http://100.65.180.100:4058/", - requestedUrl: "https://artelo.localhost/", + requestedUrl: "https://artelo.localhost", processName: "node", pid: 123, terminal: null, diff --git a/apps/web/src/components/preview/previewEmptyStateLogic.ts b/apps/web/src/components/preview/previewEmptyStateLogic.ts index 93c78dfc7ede..77e6338dffc8 100644 --- a/apps/web/src/components/preview/previewEmptyStateLogic.ts +++ b/apps/web/src/components/preview/previewEmptyStateLogic.ts @@ -1,5 +1,7 @@ import type { PreviewSessionSnapshot, ProjectScript } from "@t3tools/contracts"; +import { normalizeHistoryUrl } from "~/browserHistoryStore"; + import type { PreviewableServer } from "./useDiscoveredLocalServers"; export function shouldShowPreviewEmptyState(snapshot: PreviewSessionSnapshot | null): boolean { @@ -16,5 +18,7 @@ export function findDiscoveredServerTargetPort( url: string, servers: ReadonlyArray, ): number | undefined { - return servers.find((server) => server.requestedUrl === url)?.port; + const normalizedUrl = normalizeHistoryUrl(url); + if (normalizedUrl === null) return undefined; + return servers.find((server) => normalizeHistoryUrl(server.requestedUrl) === normalizedUrl)?.port; } From afccce313e0372f28c8a3005206aa127101d3d33 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 13 Aug 2026 16:16:33 -0400 Subject: [PATCH 06/10] fix(preview): match history URLs by origin --- .../src/components/preview/previewEmptyStateLogic.test.ts | 6 +++++- apps/web/src/components/preview/previewEmptyStateLogic.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/preview/previewEmptyStateLogic.test.ts b/apps/web/src/components/preview/previewEmptyStateLogic.test.ts index ce4cb4b05b74..552f4c0e0db3 100644 --- a/apps/web/src/components/preview/previewEmptyStateLogic.test.ts +++ b/apps/web/src/components/preview/previewEmptyStateLogic.test.ts @@ -60,7 +60,11 @@ describe("findDiscoveredServerTargetPort", () => { listening: true, } satisfies PreviewableServer; - expect(findDiscoveredServerTargetPort("https://artelo.localhost/", [server])).toBe(4058); + expect( + findDiscoveredServerTargetPort("https://artelo.localhost/products?sort=new#featured", [ + server, + ]), + ).toBe(4058); expect(findDiscoveredServerTargetPort("https://other.localhost/", [server])).toBeUndefined(); }); }); diff --git a/apps/web/src/components/preview/previewEmptyStateLogic.ts b/apps/web/src/components/preview/previewEmptyStateLogic.ts index 77e6338dffc8..5c9154fce59f 100644 --- a/apps/web/src/components/preview/previewEmptyStateLogic.ts +++ b/apps/web/src/components/preview/previewEmptyStateLogic.ts @@ -20,5 +20,9 @@ export function findDiscoveredServerTargetPort( ): number | undefined { const normalizedUrl = normalizeHistoryUrl(url); if (normalizedUrl === null) return undefined; - return servers.find((server) => normalizeHistoryUrl(server.requestedUrl) === normalizedUrl)?.port; + const origin = new URL(normalizedUrl).origin; + return servers.find((server) => { + const normalizedRequestedUrl = normalizeHistoryUrl(server.requestedUrl); + return normalizedRequestedUrl !== null && new URL(normalizedRequestedUrl).origin === origin; + })?.port; } From bda09ec6c39dde715580c5c039a8aaf9dcd09bce Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 25 Aug 2026 22:51:37 -0400 Subject: [PATCH 07/10] fix(preview): open ngrok tunnel URLs --- apps/server/src/preview/PortScanner.test.ts | 90 +++++++++- apps/server/src/preview/PortScanner.ts | 154 ++++++++++++++++-- .../src/browser/browserTargetResolver.test.ts | 12 ++ .../preview/previewEmptyStateLogic.test.ts | 1 - .../preview/useDiscoveredLocalServers.test.ts | 11 ++ docs/user/thread-sidebar.md | 4 +- packages/contracts/src/preview.ts | 2 +- 7 files changed, 256 insertions(+), 18 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 3476d6ecabba..5ad41444958e 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -7,7 +7,7 @@ import { PREVIEW_URL_MAX_LENGTH, type DiscoveredLocalServer, } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Net from "@t3tools/shared/Net"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -61,6 +61,7 @@ const makeProbeFailureLayer = ( Layer.provide( Layer.mergeAll( NodeServices.layer, + Layer.succeed(HostProcessEnvironment, {}), Layer.succeed(ProcessRunner.ProcessRunner, { run }), Layer.succeed(Net.NetService, { canListenOnHost: () => Effect.succeed(true), @@ -79,6 +80,7 @@ const TestPortDiscoveryLive = PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( NodeServices.layer, + Layer.succeed(HostProcessEnvironment, {}), TestProcessRunner, TestIntegrationNet, Layer.succeed(HostProcessPlatform, "win32"), @@ -97,6 +99,7 @@ const makeLsofScannerLayer = (input: { Layer.provide( Layer.mergeAll( NodeServices.layer, + Layer.succeed(HostProcessEnvironment, {}), Layer.succeed(ProcessRunner.ProcessRunner, { run: () => Effect.succeed({ @@ -142,7 +145,7 @@ describe("Portless route enrichment", () => { expect(routes.get(4058)).toBe("https://eng-1252-simplify-the-onboarding.artelo.localhost"); expect( - PortScanner.__testing.applyPortlessRoutes( + PortScanner.__testing.applyNamedRoutes( [ { host: "localhost", @@ -194,6 +197,89 @@ describe("Portless route enrichment", () => { }); }); +describe("ngrok route enrichment", () => { + it("maps public HTTP tunnels to their loopback target and prefers HTTPS", () => { + const routes = PortScanner.__testing.parseNgrokTunnelSnapshot({ + tunnels: [ + { + public_url: "http://feature.ngrok-free.app", + config: { addr: "http://localhost:4058" }, + }, + { + public_url: "https://feature.ngrok-free.app", + config: { addr: "http://127.0.0.1:4058" }, + }, + ], + }); + + expect(routes && [...routes]).toEqual([[4058, "https://feature.ngrok-free.app/"]]); + }); + + it("accepts current forwards_to entries and ignores unsafe or non-web tunnels", () => { + const routes = PortScanner.__testing.parseNgrokTunnelSnapshot({ + tunnels: [ + { public_url: "https://docs.example.com", forwards_to: "[::1]:4312" }, + { public_url: "https://invalid.example.com", forwards_to: "internal.example.com:4313" }, + { public_url: "tcp://1.tcp.ngrok.io:12345", config: { addr: "localhost:4314" } }, + { public_url: "https://user:secret@example.com", config: { addr: "4315" } }, + ], + }); + + expect(routes && [...routes]).toEqual([[4312, "https://docs.example.com/"]]); + }); + + it("returns null when the agent response does not match the tunnel list contract", () => { + expect(PortScanner.__testing.parseNgrokTunnelSnapshot({ tunnels: "invalid" })).toBeNull(); + }); +}); + +effectIt.effect("replaces a discovered listener with its ngrok public URL", () => { + const targetPort = 43_127; + const configuredUrl = `http://localhost:${targetPort}/docs?mode=test#results`; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + const url = String(input); + requests.push(url); + if (url === "http://localhost:4040/api/tunnels") { + return Promise.resolve( + Response.json({ + tunnels: [ + { + public_url: "https://feature.ngrok-free.app", + config: { addr: `http://localhost:${targetPort}` }, + }, + ], + }), + ); + } + return Promise.resolve(new Response("app", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; + const layer = makeProbeFailureLayer( + () => + Effect.succeed({ + stdout: `p1234\ncnode\nn*:${targetPort}\np5678\ncngrok\nn127.0.0.1:4040\n`, + stderr: "", + code: null, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + fetchFn, + ); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + const servers = yield* scanner.scan([configuredUrl]); + expect(servers.map((server) => [server.port, server.url])).toEqual([ + [targetPort, "https://feature.ngrok-free.app/docs?mode=test#results"], + ]); + expect(requests).toContain("http://localhost:4040/api/tunnels"); + expect(requests).toContain(configuredUrl); + }).pipe(Effect.provide(layer)); +}); + const openServer = ( port: number, onConnection: (socket: NodeNet.Socket) => void, diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 67b5c817924b..97e537749c13 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -39,7 +39,7 @@ import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; -import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ProcessRunner from "../processRunner.ts"; @@ -70,7 +70,8 @@ export class PortDiscovery extends Context.Service< >()("t3/preview/PortScanner/PortDiscovery") {} export const COMMON_DEV_PORTS: ReadonlyArray = Object.freeze([ - 3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000, + 3000, 3001, 3333, 4040, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, + 9000, ]); const POLL_INTERVAL = Duration.seconds(3); @@ -80,6 +81,7 @@ const WEB_PROBE_TIMEOUT = Duration.seconds(1); const WEB_PROBE_CACHE_TTL_MS = Duration.toMillis(Duration.seconds(15)); const WEB_PROBE_CONCURRENCY = 16; const NAVIGATION_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const NGROK_DEFAULT_AGENT_API_PORT = 4040; const PortlessRoute = Schema.Struct({ hostname: Schema.String, @@ -93,6 +95,16 @@ const decodePortlessRouteEntries = Schema.decodeUnknownOption( const PORTLESS_HOSTNAME_PATTERN = /^(?=.{1,253}$)(?:[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?\.)*[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/i; +const NgrokTunnel = Schema.Struct({ + public_url: Schema.String, + config: Schema.optional(Schema.Struct({ addr: Schema.String })), + forwards_to: Schema.optional(Schema.String), +}); +const isNgrokTunnel = Schema.is(NgrokTunnel); +const decodeNgrokTunnelList = Schema.decodeUnknownOption( + Schema.Struct({ tunnels: Schema.Array(Schema.Unknown) }), +); + interface PortlessRouteSnapshot { readonly routesJson: string; readonly proxyPortRaw: string | null; @@ -126,13 +138,58 @@ const parsePortlessRouteSnapshot = (input: PortlessRouteSnapshot): ReadonlyMap { + const target = raw.trim(); + if (/^\d+$/.test(target)) { + const port = Number(target); + return port > 0 && port < 65_536 ? port : null; + } + + try { + const url = new URL(target.includes("://") ? target : `http://${target}`); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + if (!isLoopbackHost(url.hostname)) return null; + const port = urlPort(url); + return port > 0 && port < 65_536 ? port : null; + } catch { + return null; + } +}; + +const parseNgrokTunnelSnapshot = (input: unknown): ReadonlyMap | null => { + const decoded = decodeNgrokTunnelList(input); + if (Option.isNone(decoded)) return null; + + const urlsByTargetPort = new Map(); + for (const entry of decoded.value.tunnels) { + if (!isNgrokTunnel(entry)) continue; + const targetPort = parseLoopbackTargetPort(entry.config?.addr ?? entry.forwards_to ?? ""); + if (targetPort === null) continue; + + let publicUrl: URL; + try { + publicUrl = new URL(entry.public_url.trim()); + } catch { + continue; + } + if (publicUrl.protocol !== "http:" && publicUrl.protocol !== "https:") continue; + if (publicUrl.username || publicUrl.password) continue; + + const current = urlsByTargetPort.get(targetPort); + if (current === undefined || (current.startsWith("http:") && publicUrl.protocol === "https:")) { + urlsByTargetPort.set(targetPort, publicUrl.href); + } + } + return urlsByTargetPort; +}; + +const applyNamedRoutes = ( servers: ReadonlyArray, urlsByTargetPort: ReadonlyMap, ): ReadonlyArray => servers.map((server) => { - const portlessUrl = urlsByTargetPort.get(server.port); - return portlessUrl === undefined ? server : { ...server, url: portlessUrl }; + const namedUrl = urlsByTargetPort.get(server.port); + return namedUrl === undefined ? server : { ...server, url: namedUrl }; }); type Listener = (servers: ReadonlyArray) => Effect.Effect; @@ -224,13 +281,29 @@ const projectWebProbeSnapshot = ( configuredUrls: ReadonlyArray, ): ReadonlyArray => { const visibleByServer = new Map(); + const namedRouteByServer = new Map(); + for (const server of snapshot.discovered) { + if (new URL(server.url).hostname !== server.host) { + namedRouteByServer.set(localServerKey(server.host, server.port), server); + } + } for (const raw of normalizeConfiguredUrls(configuredUrls)) { const url = new URL(raw); const port = urlPort(url); const serverKey = localServerKey(url.hostname, port); if (visibleByServer.has(serverKey)) continue; const configured = snapshot.configured.get(webProbeCacheKey(raw)); - if (configured) visibleByServer.set(serverKey, { ...configured, url: raw }); + if (!configured) continue; + const namedRoute = namedRouteByServer.get(serverKey); + if (!namedRoute) { + visibleByServer.set(serverKey, { ...configured, url: raw }); + continue; + } + const namedUrl = new URL(namedRoute.url); + namedUrl.pathname = url.pathname; + namedUrl.search = url.search; + namedUrl.hash = url.hash; + visibleByServer.set(serverKey, { ...configured, url: namedUrl.href }); } for (const server of snapshot.discovered) { const key = localServerKey(server.host, server.port); @@ -394,6 +467,47 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }); }); + const readNgrokRoutes = Effect.fn("PortDiscovery.readNgrokRoutes")(function* ( + servers: ReadonlyArray, + ) { + const candidatePorts = [ + ...new Set( + servers + .filter( + (server) => + server.port === NGROK_DEFAULT_AGENT_API_PORT || + server.processName?.toLowerCase().includes("ngrok") === true, + ) + .map((server) => server.port), + ), + ]; + const responses = yield* Effect.forEach( + candidatePorts, + (port) => + httpClient.get(`http://localhost:${port}/api/tunnels`).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.map((body) => { + const routes = parseNgrokTunnelSnapshot(body); + return routes === null ? null : { port, routes }; + }), + Effect.scoped, + Effect.timeoutOption(WEB_PROBE_TIMEOUT), + Effect.map(Option.getOrNull), + Effect.orElseSucceed(() => null), + ), + { concurrency: "unbounded" }, + ); + const agentPorts = new Set(); + const routes = new Map(); + for (const response of responses) { + if (response === null) continue; + agentPorts.add(response.port); + for (const [targetPort, publicUrl] of response.routes) routes.set(targetPort, publicUrl); + } + return { agentPorts, routes }; + }); + const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { const results = yield* Effect.forEach( COMMON_DEV_PORTS, @@ -561,6 +675,23 @@ export const make = Effect.gen(function* PortDiscoveryMake() { return { discovered, configured } satisfies WebProbeSnapshot; }); + const probeAndEnrichWebServers = Effect.fn("PortDiscovery.probeAndEnrichWebServers")(function* ( + servers: ReadonlyArray, + configuredUrls: ReadonlyArray, + ) { + const [portlessRoutes, ngrok] = yield* Effect.all([ + readPortlessRoutes(), + readNgrokRoutes(servers), + ]); + const snapshot = yield* probeWebServers( + servers.filter((server) => !ngrok.agentPorts.has(server.port)), + configuredUrls, + ); + const namedRoutes = new Map(portlessRoutes); + for (const [targetPort, publicUrl] of ngrok.routes) namedRoutes.set(targetPort, publicUrl); + return { ...snapshot, discovered: applyNamedRoutes(snapshot.discovered, namedRoutes) }; + }); + const recoverProcessProbeFailure = (probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) => Effect.logDebug("preview port process probe failed; falling back to common-port probes", { @@ -573,7 +704,6 @@ export const make = Effect.gen(function* PortDiscoveryMake() { configuredUrls: ReadonlyArray, ) { const state = yield* Ref.get(stateRef); - const portlessRoutes = yield* readPortlessRoutes(); const terminalByProcessId = new Map(); for (const registration of state.terminalProcesses.values()) { for (const processId of registration.processIds) { @@ -602,11 +732,10 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ProcessTimeoutError: recoverWindowsProbeFailure, }), ); - const snapshot = yield* probeWebServers( + return yield* probeAndEnrichWebServers( listeners ?? (yield* probeCommonPorts()), configuredUrls, ); - return { ...snapshot, discovered: applyPortlessRoutes(snapshot.discovered, portlessRoutes) }; } const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); const lsofResult = yield* processRunner @@ -627,11 +756,10 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ProcessTimeoutError: recoverLsofProbeFailure, }), ); - const snapshot = yield* probeWebServers( + return yield* probeAndEnrichWebServers( lsofResult ?? (yield* probeCommonPorts()), configuredUrls, ); - return { ...snapshot, discovered: applyPortlessRoutes(snapshot.discovered, portlessRoutes) }; }); const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")( @@ -763,6 +891,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() { export const layer = Layer.effect(PortDiscovery, make); export const __testing = { - applyPortlessRoutes, + applyNamedRoutes, + parseLoopbackTargetPort, + parseNgrokTunnelSnapshot, parsePortlessRouteSnapshot, }; diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index 1abd269ff512..4cac6f5ee5d1 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -168,6 +168,18 @@ describe("browser target resolver", () => { ).toBe("http://100.65.180.100:4058/dashboard?mode=test#results"); }); + it("opens an ngrok URL directly for a remote environment", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect( + resolveDiscoveredServerUrl( + EnvironmentId.make("environment-1"), + "https://feature.ngrok-free.app/dashboard?mode=test#results", + 4058, + ), + ).toBe("https://feature.ngrok-free.app/dashboard?mode=test#results"); + }); + 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( diff --git a/apps/web/src/components/preview/previewEmptyStateLogic.test.ts b/apps/web/src/components/preview/previewEmptyStateLogic.test.ts index 552f4c0e0db3..af17eff2a488 100644 --- a/apps/web/src/components/preview/previewEmptyStateLogic.test.ts +++ b/apps/web/src/components/preview/previewEmptyStateLogic.test.ts @@ -57,7 +57,6 @@ describe("findDiscoveredServerTargetPort", () => { pid: 123, terminal: null, source: "scanner", - listening: true, } satisfies PreviewableServer; expect( diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts index 7605e5246b4e..309a1d25972b 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts @@ -156,6 +156,12 @@ describe("formatDiscoveredServerHost", () => { ).toBe("feature-branch.artelo.localhost"); }); + it("shows an ngrok host instead of its underlying listener", () => { + expect( + formatDiscoveredServerHost(scannerServer({ url: "https://feature.ngrok-free.app/" })), + ).toBe("feature.ngrok-free.app"); + }); + it("falls back to the listener host and port for an invalid URL", () => { expect(formatDiscoveredServerHost(scannerServer({ url: "not a url" }))).toBe("localhost:5173"); }); @@ -169,6 +175,11 @@ describe("selectPreferredDiscoveredServer", () => { ); }); + it("prefers an ngrok tunnel over a bare listener", () => { + const ngrok = scannerServer({ port: 4314, url: "https://feature.ngrok-free.app/" }); + expect(selectPreferredDiscoveredServer([scannerServer({ port: 4004 }), ngrok])).toBe(ngrok); + }); + it("falls back to the first listener when none has a named URL", () => { const first = scannerServer({ port: 3000, url: "http://localhost:3000" }); expect(selectPreferredDiscoveredServer([first, scannerServer({ port: 5173 })])).toBe(first); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 1d6518dff7aa..e94f86c5339e 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -33,5 +33,5 @@ be selected again. The option is hidden when the connected environment needs a s ## Dev server shortcuts When a terminal starts a local dev server, a globe appears on its thread. Select it to open the -server in T3 Code's browser. T3 Code recognizes Portless routes and opens their stable named URL -instead of the underlying `localhost` port. +server in T3 Code's browser. T3 Code recognizes Portless routes and active ngrok HTTP tunnels, then +opens their named or public URL instead of the underlying `localhost` port. diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index 5a6eb8eb0c41..49998947dcf8 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -290,7 +290,7 @@ export type PreviewEvent = typeof PreviewEvent.Type; /** * A local server detected by the port scanner. `url` may be a named local - * proxy URL while `host` and `port` identify the underlying listener. + * proxy or public tunnel URL while `host` and `port` identify the underlying listener. */ export const DiscoveredLocalServer = Schema.Struct({ host: TrimmedNonEmptyString, From 9e1c6549114857b871cbe56428e7ed41456d184c Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 25 Aug 2026 22:54:59 -0400 Subject: [PATCH 08/10] style(preview): format server shortcut card --- .../src/components/preview/PreviewLocalServerCard.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/preview/PreviewLocalServerCard.tsx b/apps/web/src/components/preview/PreviewLocalServerCard.tsx index ea47b17cbf41..a057d88c081e 100644 --- a/apps/web/src/components/preview/PreviewLocalServerCard.tsx +++ b/apps/web/src/components/preview/PreviewLocalServerCard.tsx @@ -1,10 +1,7 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; import { PreviewFaviconIcon } from "./PreviewFaviconIcon"; -import { - formatDiscoveredServerHost, - type PreviewableServer, -} from "./useDiscoveredLocalServers"; +import { formatDiscoveredServerHost, type PreviewableServer } from "./useDiscoveredLocalServers"; interface Props { threadRef: ScopedThreadRef; @@ -23,9 +20,9 @@ export function PreviewLocalServerCard({ threadRef, server, onOpen }: Props) {
{subtitle} - - {formatDiscoveredServerHost(server)} - + + {formatDiscoveredServerHost(server)} +
); From 1fb6bb3d3b54c9970b12e5bad02f04a1ae0e7787 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 25 Aug 2026 23:10:07 -0400 Subject: [PATCH 09/10] fix(preview): distinguish proxy and tunnel URLs --- apps/server/src/preview/PortScanner.test.ts | 52 +++++++++-- apps/server/src/preview/PortScanner.ts | 89 +++++++++++-------- .../src/browser/browserTargetResolver.test.ts | 4 +- apps/web/src/browser/browserTargetResolver.ts | 13 +-- .../preview/PreviewEmptyState.test.tsx | 15 ++++ .../components/preview/PreviewEmptyState.tsx | 19 ++-- .../preview/PreviewLocalServerCard.tsx | 2 +- .../components/preview/PreviewView.test.tsx | 11 ++- .../src/components/preview/PreviewView.tsx | 14 ++- .../components/preview/openDiscoveredPort.ts | 1 + .../preview/previewEmptyStateLogic.test.ts | 13 ++- .../preview/previewEmptyStateLogic.ts | 11 +-- .../preview/useDiscoveredLocalServers.test.ts | 12 ++- .../preview/useDiscoveredLocalServers.ts | 8 +- packages/contracts/src/preview.test.ts | 2 + packages/contracts/src/preview.ts | 4 + 16 files changed, 192 insertions(+), 78 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 5ad41444958e..3d308730247e 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -143,7 +143,10 @@ describe("Portless route enrichment", () => { isProcessAlive: (pid) => pid === 123, }); - expect(routes.get(4058)).toBe("https://eng-1252-simplify-the-onboarding.artelo.localhost"); + expect(routes.get(4058)).toEqual({ + url: "https://eng-1252-simplify-the-onboarding.artelo.localhost", + urlKind: "local-proxy", + }); expect( PortScanner.__testing.applyNamedRoutes( [ @@ -163,6 +166,7 @@ describe("Portless route enrichment", () => { host: "localhost", port: 4058, url: "https://eng-1252-simplify-the-onboarding.artelo.localhost", + urlKind: "local-proxy", processName: "node", pid: 456, terminal: null, @@ -182,7 +186,9 @@ describe("Portless route enrichment", () => { isProcessAlive: (pid) => pid === 222, }); - expect([...routes]).toEqual([[3001, "http://current.test:8080"]]); + expect([...routes]).toEqual([ + [3001, { url: "http://current.test:8080", urlKind: "local-proxy" }], + ]); }); it.each(["8080abc", "443.5"])("ignores a malformed proxy port of %s", (proxyPortRaw) => { @@ -193,7 +199,7 @@ describe("Portless route enrichment", () => { isProcessAlive: (pid) => pid === 222, }); - expect([...routes]).toEqual([[3001, "https://current.test"]]); + expect([...routes]).toEqual([[3001, { url: "https://current.test", urlKind: "local-proxy" }]]); }); }); @@ -212,7 +218,9 @@ describe("ngrok route enrichment", () => { ], }); - expect(routes && [...routes]).toEqual([[4058, "https://feature.ngrok-free.app/"]]); + expect(routes && [...routes]).toEqual([ + [4058, { url: "https://feature.ngrok-free.app/", urlKind: "public-tunnel" }], + ]); }); it("accepts current forwards_to entries and ignores unsafe or non-web tunnels", () => { @@ -225,7 +233,9 @@ describe("ngrok route enrichment", () => { ], }); - expect(routes && [...routes]).toEqual([[4312, "https://docs.example.com/"]]); + expect(routes && [...routes]).toEqual([ + [4312, { url: "https://docs.example.com/", urlKind: "public-tunnel" }], + ]); }); it("returns null when the agent response does not match the tunnel list contract", () => { @@ -272,14 +282,42 @@ effectIt.effect("replaces a discovered listener with its ngrok public URL", () = return Effect.gen(function* () { const scanner = yield* PortScanner.PortDiscovery; const servers = yield* scanner.scan([configuredUrl]); - expect(servers.map((server) => [server.port, server.url])).toEqual([ - [targetPort, "https://feature.ngrok-free.app/docs?mode=test#results"], + expect(servers.map((server) => [server.port, server.url, server.urlKind])).toEqual([ + [targetPort, "https://feature.ngrok-free.app/docs?mode=test#results", "public-tunnel"], ]); expect(requests).toContain("http://localhost:4040/api/tunnels"); expect(requests).toContain(configuredUrl); }).pipe(Effect.provide(layer)); }); +effectIt.effect("keeps a non-ngrok web server on the default agent port", () => { + const fetchFn = ((input: Parameters[0]) => + Promise.resolve( + String(input) === "http://localhost:4040/api/tunnels" + ? Response.json({ tunnels: [] }) + : new Response("app", { headers: { "content-type": "text/html" } }), + )) as typeof globalThis.fetch; + const layer = makeProbeFailureLayer( + () => + Effect.succeed({ + stdout: "p1234\ncnode\nn*:4040\n", + stderr: "", + code: null, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + fetchFn, + ); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + expect(yield* scanner.scan()).toMatchObject([{ port: 4040, url: "http://localhost:4040" }]); + }).pipe(Effect.provide(layer)); +}); + const openServer = ( port: number, onConnection: (socket: NodeNet.Socket) => void, diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 97e537749c13..f02308fa5bb9 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -21,6 +21,7 @@ import { PREVIEW_URL_MAX_LENGTH, ThreadId, type DiscoveredLocalServer, + type DiscoveredLocalServerUrlKind, } from "@t3tools/contracts"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Net from "@t3tools/shared/Net"; @@ -112,7 +113,14 @@ interface PortlessRouteSnapshot { readonly isProcessAlive: (pid: number) => boolean; } -const parsePortlessRouteSnapshot = (input: PortlessRouteSnapshot): ReadonlyMap => { +interface NamedRoute { + readonly url: string; + readonly urlKind: DiscoveredLocalServerUrlKind; +} + +const parsePortlessRouteSnapshot = ( + input: PortlessRouteSnapshot, +): ReadonlyMap => { const decoded = decodePortlessRouteEntries(input.routesJson); if (Option.isNone(decoded)) return new Map(); @@ -123,19 +131,22 @@ const parsePortlessRouteSnapshot = (input: PortlessRouteSnapshot): ReadonlyMap(); + const routesByTargetPort = new Map(); for (const entry of decoded.value) { if (!isPortlessRoute(entry)) continue; if (entry.pid !== 0 && !input.isProcessAlive(entry.pid)) continue; if (!PORTLESS_HOSTNAME_PATTERN.test(entry.hostname)) continue; - if (urlsByTargetPort.has(entry.port)) continue; + if (routesByTargetPort.has(entry.port)) continue; const portSuffix = proxyPort === defaultProxyPort ? "" : `:${proxyPort}`; - urlsByTargetPort.set(entry.port, `${protocol}://${entry.hostname}${portSuffix}`); + routesByTargetPort.set(entry.port, { + url: `${protocol}://${entry.hostname}${portSuffix}`, + urlKind: "local-proxy", + }); } - return urlsByTargetPort; + return routesByTargetPort; }; const parseLoopbackTargetPort = (raw: string): number | null => { @@ -156,11 +167,11 @@ const parseLoopbackTargetPort = (raw: string): number | null => { } }; -const parseNgrokTunnelSnapshot = (input: unknown): ReadonlyMap | null => { +const parseNgrokTunnelSnapshot = (input: unknown): ReadonlyMap | null => { const decoded = decodeNgrokTunnelList(input); if (Option.isNone(decoded)) return null; - const urlsByTargetPort = new Map(); + const routesByTargetPort = new Map(); for (const entry of decoded.value.tunnels) { if (!isNgrokTunnel(entry)) continue; const targetPort = parseLoopbackTargetPort(entry.config?.addr ?? entry.forwards_to ?? ""); @@ -175,21 +186,27 @@ const parseNgrokTunnelSnapshot = (input: unknown): ReadonlyMap | if (publicUrl.protocol !== "http:" && publicUrl.protocol !== "https:") continue; if (publicUrl.username || publicUrl.password) continue; - const current = urlsByTargetPort.get(targetPort); - if (current === undefined || (current.startsWith("http:") && publicUrl.protocol === "https:")) { - urlsByTargetPort.set(targetPort, publicUrl.href); + const current = routesByTargetPort.get(targetPort); + if ( + current === undefined || + (current.url.startsWith("http:") && publicUrl.protocol === "https:") + ) { + routesByTargetPort.set(targetPort, { + url: publicUrl.href, + urlKind: "public-tunnel", + }); } } - return urlsByTargetPort; + return routesByTargetPort; }; const applyNamedRoutes = ( servers: ReadonlyArray, - urlsByTargetPort: ReadonlyMap, + routesByTargetPort: ReadonlyMap, ): ReadonlyArray => servers.map((server) => { - const namedUrl = urlsByTargetPort.get(server.port); - return namedUrl === undefined ? server : { ...server, url: namedUrl }; + const route = routesByTargetPort.get(server.port); + return route === undefined ? server : { ...server, ...route }; }); type Listener = (servers: ReadonlyArray) => Effect.Effect; @@ -283,7 +300,7 @@ const projectWebProbeSnapshot = ( const visibleByServer = new Map(); const namedRouteByServer = new Map(); for (const server of snapshot.discovered) { - if (new URL(server.url).hostname !== server.host) { + if (server.urlKind !== undefined) { namedRouteByServer.set(localServerKey(server.host, server.port), server); } } @@ -303,7 +320,11 @@ const projectWebProbeSnapshot = ( namedUrl.pathname = url.pathname; namedUrl.search = url.search; namedUrl.hash = url.hash; - visibleByServer.set(serverKey, { ...configured, url: namedUrl.href }); + visibleByServer.set(serverKey, { + ...configured, + url: namedUrl.href, + urlKind: namedRoute.urlKind, + }); } for (const server of snapshot.discovered) { const key = localServerKey(server.host, server.port); @@ -408,6 +429,7 @@ const serversEqual = ( a.host !== b.host || a.port !== b.port || a.url !== b.url || + a.urlKind !== b.urlKind || a.processName !== b.processName || a.pid !== b.pid || a.terminal?.threadId !== b.terminal?.threadId || @@ -439,11 +461,11 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const configuredStateDir = hostEnvironment.PORTLESS_STATE_DIR?.trim(); const homeDirectory = hostEnvironment.HOME?.trim() || hostEnvironment.USERPROFILE?.trim(); const stateDir = configuredStateDir || (homeDirectory && path.join(homeDirectory, ".portless")); - if (!stateDir) return new Map(); + if (!stateDir) return new Map(); const routesJson = yield* fileSystem .readFileString(path.join(stateDir, "routes.json")) .pipe(Effect.option); - if (Option.isNone(routesJson)) return new Map(); + if (Option.isNone(routesJson)) return new Map(); const proxyPortRaw = yield* fileSystem .readFileString(path.join(stateDir, "proxy.port")) @@ -470,26 +492,21 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const readNgrokRoutes = Effect.fn("PortDiscovery.readNgrokRoutes")(function* ( servers: ReadonlyArray, ) { - const candidatePorts = [ - ...new Set( - servers - .filter( - (server) => - server.port === NGROK_DEFAULT_AGENT_API_PORT || - server.processName?.toLowerCase().includes("ngrok") === true, - ) - .map((server) => server.port), - ), - ]; + const candidatesByPort = new Map(); + for (const server of servers) { + const processNameIsNgrok = server.processName?.toLowerCase() === "ngrok"; + if (server.port !== NGROK_DEFAULT_AGENT_API_PORT && !processNameIsNgrok) continue; + candidatesByPort.set(server.port, candidatesByPort.get(server.port) || processNameIsNgrok); + } const responses = yield* Effect.forEach( - candidatePorts, - (port) => + candidatesByPort, + ([port, processNameIsNgrok]) => httpClient.get(`http://localhost:${port}/api/tunnels`).pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.flatMap((response) => response.json), Effect.map((body) => { const routes = parseNgrokTunnelSnapshot(body); - return routes === null ? null : { port, routes }; + return routes === null ? null : { port, processNameIsNgrok, routes }; }), Effect.scoped, Effect.timeoutOption(WEB_PROBE_TIMEOUT), @@ -499,11 +516,11 @@ export const make = Effect.gen(function* PortDiscoveryMake() { { concurrency: "unbounded" }, ); const agentPorts = new Set(); - const routes = new Map(); + const routes = new Map(); for (const response of responses) { if (response === null) continue; - agentPorts.add(response.port); - for (const [targetPort, publicUrl] of response.routes) routes.set(targetPort, publicUrl); + if (response.routes.size > 0 || response.processNameIsNgrok) agentPorts.add(response.port); + for (const [targetPort, route] of response.routes) routes.set(targetPort, route); } return { agentPorts, routes }; }); @@ -688,7 +705,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { configuredUrls, ); const namedRoutes = new Map(portlessRoutes); - for (const [targetPort, publicUrl] of ngrok.routes) namedRoutes.set(targetPort, publicUrl); + for (const [targetPort, route] of ngrok.routes) namedRoutes.set(targetPort, route); return { ...snapshot, discovered: applyNamedRoutes(snapshot.discovered, namedRoutes) }; }); diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index 4cac6f5ee5d1..b7a0c7122e7f 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -162,8 +162,9 @@ describe("browser target resolver", () => { expect( resolveDiscoveredServerUrl( EnvironmentId.make("environment-1"), - "https://feature.artelo.localhost/dashboard?mode=test#results", + "https://current.test/dashboard?mode=test#results", 4058, + "local-proxy", ), ).toBe("http://100.65.180.100:4058/dashboard?mode=test#results"); }); @@ -176,6 +177,7 @@ describe("browser target resolver", () => { EnvironmentId.make("environment-1"), "https://feature.ngrok-free.app/dashboard?mode=test#results", 4058, + "public-tunnel", ), ).toBe("https://feature.ngrok-free.app/dashboard?mode=test#results"); }); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index d7bcc089094d..c7761d87caf9 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -1,5 +1,6 @@ import type { BrowserNavigationTarget, + DiscoveredLocalServerUrlKind, EnvironmentId, PreviewUrlResolution, } from "@t3tools/contracts"; @@ -245,15 +246,17 @@ export function resolveDiscoveredServerUrl( environmentId: EnvironmentId, rawUrl: string, targetPort?: number, + urlKind?: DiscoveredLocalServerUrlKind, ): string { try { const normalizedUrl = normalizePreviewUrl(rawUrl); const parsed = new URL(normalizedUrl); - if ( - targetPort !== undefined && - parsed.hostname !== "localhost" && - parsed.hostname.endsWith(".localhost") - ) { + const isLocalProxy = + urlKind === "local-proxy" || + (urlKind === undefined && + parsed.hostname !== "localhost" && + parsed.hostname.endsWith(".localhost")); + if (targetPort !== undefined && isLocalProxy) { const environmentUrl = readEnvironmentUrl(environmentId); if (!isLocalLoopbackHost(environmentUrl.hostname)) { return resolveEnvironmentPortTarget( diff --git a/apps/web/src/components/preview/PreviewEmptyState.test.tsx b/apps/web/src/components/preview/PreviewEmptyState.test.tsx index c89146d61134..bcde0a73c7ff 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.test.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.test.tsx @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ host: string; port: number; url: string; + urlKind?: "local-proxy" | "public-tunnel"; requestedUrl: string; processName: string | null; pid: number | null; @@ -75,6 +76,20 @@ describe("PreviewEmptyState", () => { expect(html).not.toContain("Local servers"); }); + it("labels a remote proxy from its stable requested URL", () => { + mocks.servers = [ + { + ...server(4058), + url: "http://100.65.180.100:4058/", + requestedUrl: "https://feature.artelo.localhost/", + urlKind: "local-proxy", + }, + ]; + const html = render([]); + expect(html).toContain("feature.artelo.localhost"); + expect(html).not.toContain("100.65.180.100:4058"); + }); + it("keeps the original empty state when both groups are empty", () => { mocks.servers = []; const html = render([]); diff --git a/apps/web/src/components/preview/PreviewEmptyState.tsx b/apps/web/src/components/preview/PreviewEmptyState.tsx index 1498df4890c6..fc8a31c9b465 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.tsx @@ -1,4 +1,8 @@ -import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; +import type { + DiscoveredLocalServerUrlKind, + EnvironmentId, + ScopedThreadRef, +} from "@t3tools/contracts"; import { Globe, History, RadioTower } from "lucide-react"; import type { BrowserHistoryEntry } from "~/browserHistoryStore"; @@ -6,7 +10,7 @@ import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui import { PreviewLocalServerCard } from "./PreviewLocalServerCard"; import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard"; -import { findDiscoveredServerTargetPort } from "./previewEmptyStateLogic"; +import { findDiscoveredServerTarget } from "./previewEmptyStateLogic"; import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers"; interface Props { @@ -15,7 +19,7 @@ interface Props { configuredUrls?: ReadonlyArray | undefined; recentEntries: ReadonlyArray; onRemoveRecent: (url: string) => void; - onOpenUrl: (url: string, targetPort?: number) => void; + onOpenUrl: (url: string, targetPort?: number, urlKind?: DiscoveredLocalServerUrlKind) => void; } export function PreviewEmptyState({ @@ -62,9 +66,10 @@ export function PreviewEmptyState({ key={entry.url} threadRef={threadRef} entry={entry} - onOpen={() => - onOpenUrl(entry.url, findDiscoveredServerTargetPort(entry.url, servers)) - } + onOpen={() => { + const target = findDiscoveredServerTarget(entry.url, servers); + onOpenUrl(entry.url, target?.port, target?.urlKind); + }} onRemove={() => onRemoveRecent(entry.url)} /> ))} @@ -83,7 +88,7 @@ export function PreviewEmptyState({ key={`${server.host}:${server.port}`} threadRef={threadRef} server={server} - onOpen={() => onOpenUrl(server.requestedUrl, server.port)} + onOpen={() => onOpenUrl(server.requestedUrl, server.port, server.urlKind)} /> ))} diff --git a/apps/web/src/components/preview/PreviewLocalServerCard.tsx b/apps/web/src/components/preview/PreviewLocalServerCard.tsx index a057d88c081e..84d5b64819f3 100644 --- a/apps/web/src/components/preview/PreviewLocalServerCard.tsx +++ b/apps/web/src/components/preview/PreviewLocalServerCard.tsx @@ -21,7 +21,7 @@ export function PreviewLocalServerCard({ threadRef, server, onOpen }: Props) {
{subtitle} - {formatDiscoveredServerHost(server)} + {formatDiscoveredServerHost({ ...server, url: server.requestedUrl })}
diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index ec060913e37f..9a4fae7616ee 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -1,6 +1,7 @@ import { DEFAULT_PREVIEW_APPEARANCE, DEFAULT_PREVIEW_ZOOM_FACTOR, + type DiscoveredLocalServerUrlKind, EnvironmentId, FILL_PREVIEW_VIEWPORT, ThreadId, @@ -14,7 +15,9 @@ const mocks = vi.hoisted(() => ({ rememberPreviewUrl: vi.fn(), readPreparedConnection: vi.fn(() => ({ httpBaseUrl: "http://172.25.85.75:3773" })), submittedUrl: null as ((url: string) => void) | null, - emptyStateUrl: null as ((url: string, targetPort?: number) => void) | null, + emptyStateUrl: null as + | ((url: string, targetPort?: number, urlKind?: DiscoveredLocalServerUrlKind) => void) + | null, togglePictureInPicture: null as (() => void) | null, toggleNativePictureInPicture: null as (() => void) | null, pictureInPicturePressed: false, @@ -231,7 +234,9 @@ vi.mock("./PreviewChromeRow", () => ({ })); vi.mock("./PreviewEmptyState", () => ({ - PreviewEmptyState: (props: { onOpenUrl: (url: string, targetPort?: number) => void }) => { + PreviewEmptyState: (props: { + onOpenUrl: (url: string, targetPort?: number, urlKind?: DiscoveredLocalServerUrlKind) => void; + }) => { mocks.emptyStateUrl = props.onOpenUrl; return null; }, @@ -470,7 +475,7 @@ describe("PreviewView navigation", () => { renderToStaticMarkup(); expect(mocks.emptyStateUrl).not.toBeNull(); - mocks.emptyStateUrl?.("https://artelo.localhost/", 4058); + mocks.emptyStateUrl?.("https://current.test/", 4058, "local-proxy"); await vi.waitFor(() => expect(mocks.navigate).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID, "http://172.25.85.75:4058/"), diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index df35c950a155..bc64dc6010ce 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -4,6 +4,7 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { FILL_PREVIEW_VIEWPORT, + type DiscoveredLocalServerUrlKind, type PreviewAnnotationPayload, type PreviewViewportSetting, type ScopedThreadRef, @@ -187,9 +188,14 @@ export function PreviewView({ ); const handleOpenServerUrl = useCallback( - async (next: string, targetPort?: number) => { + async (next: string, targetPort?: number, urlKind?: DiscoveredLocalServerUrlKind) => { try { - const resolved = resolveDiscoveredServerUrl(threadRef.environmentId, next, targetPort); + const resolved = resolveDiscoveredServerUrl( + threadRef.environmentId, + next, + targetPort, + urlKind, + ); if (await navigateToResolvedUrl(resolved)) { recordVisitForThread(threadRef, next); } @@ -715,7 +721,9 @@ export function PreviewView({ configuredUrls={configuredUrls} recentEntries={recentHistoryEntries} onRemoveRecent={(url) => removeUrlForThread(threadRef, url)} - onOpenUrl={(next, targetPort) => void handleOpenServerUrl(next, targetPort)} + onOpenUrl={(next, targetPort, urlKind) => + void handleOpenServerUrl(next, targetPort, urlKind) + } /> ) : null} {snapshot && desktopOverlay ? ( diff --git a/apps/web/src/components/preview/openDiscoveredPort.ts b/apps/web/src/components/preview/openDiscoveredPort.ts index 71a0072b240d..e17e07a1b245 100644 --- a/apps/web/src/components/preview/openDiscoveredPort.ts +++ b/apps/web/src/components/preview/openDiscoveredPort.ts @@ -19,6 +19,7 @@ export async function openDiscoveredPort(input: { input.threadRef.environmentId, input.port.url, input.port.port, + input.port.urlKind, ); const result = await openPreviewSession({ openPreview: input.openPreview, diff --git a/apps/web/src/components/preview/previewEmptyStateLogic.test.ts b/apps/web/src/components/preview/previewEmptyStateLogic.test.ts index af17eff2a488..d65b143a5d8d 100644 --- a/apps/web/src/components/preview/previewEmptyStateLogic.test.ts +++ b/apps/web/src/components/preview/previewEmptyStateLogic.test.ts @@ -2,7 +2,7 @@ import type { PreviewSessionSnapshot, ProjectScript } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { - findDiscoveredServerTargetPort, + findDiscoveredServerTarget, getConfiguredPreviewUrls, shouldShowPreviewEmptyState, } from "./previewEmptyStateLogic"; @@ -46,7 +46,7 @@ describe("getConfiguredPreviewUrls", () => { }); }); -describe("findDiscoveredServerTargetPort", () => { +describe("findDiscoveredServerTarget", () => { it("finds the listener port for a Portless history URL", () => { const server = { host: "localhost", @@ -57,13 +57,12 @@ describe("findDiscoveredServerTargetPort", () => { pid: 123, terminal: null, source: "scanner", + urlKind: "local-proxy", } satisfies PreviewableServer; expect( - findDiscoveredServerTargetPort("https://artelo.localhost/products?sort=new#featured", [ - server, - ]), - ).toBe(4058); - expect(findDiscoveredServerTargetPort("https://other.localhost/", [server])).toBeUndefined(); + findDiscoveredServerTarget("https://artelo.localhost/products?sort=new#featured", [server]), + ).toEqual({ port: 4058, urlKind: "local-proxy" }); + expect(findDiscoveredServerTarget("https://other.localhost/", [server])).toBeUndefined(); }); }); diff --git a/apps/web/src/components/preview/previewEmptyStateLogic.ts b/apps/web/src/components/preview/previewEmptyStateLogic.ts index 5c9154fce59f..8ff6c24c77c5 100644 --- a/apps/web/src/components/preview/previewEmptyStateLogic.ts +++ b/apps/web/src/components/preview/previewEmptyStateLogic.ts @@ -14,15 +14,16 @@ export function getConfiguredPreviewUrls( return scripts?.flatMap((script) => (script.previewUrl ? [script.previewUrl] : [])) ?? []; } -export function findDiscoveredServerTargetPort( +export function findDiscoveredServerTarget( url: string, servers: ReadonlyArray, -): number | undefined { +): { readonly port: number; readonly urlKind: PreviewableServer["urlKind"] } | undefined { const normalizedUrl = normalizeHistoryUrl(url); if (normalizedUrl === null) return undefined; const origin = new URL(normalizedUrl).origin; - return servers.find((server) => { - const normalizedRequestedUrl = normalizeHistoryUrl(server.requestedUrl); + const server = servers.find((candidate) => { + const normalizedRequestedUrl = normalizeHistoryUrl(candidate.requestedUrl); return normalizedRequestedUrl !== null && new URL(normalizedRequestedUrl).origin === origin; - })?.port; + }); + return server ? { port: server.port, urlKind: server.urlKind } : undefined; } diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts index 309a1d25972b..0e91b62bbaf9 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts @@ -169,14 +169,22 @@ describe("formatDiscoveredServerHost", () => { describe("selectPreferredDiscoveredServer", () => { it("prefers a named proxy over another listener from the same terminal", () => { - const portless = scannerServer({ port: 4314, url: "https://artelo.localhost" }); + const portless = scannerServer({ + port: 4314, + url: "https://artelo.localhost", + urlKind: "local-proxy", + }); expect(selectPreferredDiscoveredServer([scannerServer({ port: 4004 }), portless])).toBe( portless, ); }); it("prefers an ngrok tunnel over a bare listener", () => { - const ngrok = scannerServer({ port: 4314, url: "https://feature.ngrok-free.app/" }); + const ngrok = scannerServer({ + port: 4314, + url: "https://feature.ngrok-free.app/", + urlKind: "public-tunnel", + }); expect(selectPreferredDiscoveredServer([scannerServer({ port: 4004 }), ngrok])).toBe(ngrok); }); diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.ts index ffb40d2bc474..c805a6e354d9 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.ts @@ -34,7 +34,12 @@ export function useDiscoveredLocalServers( mergeServers({ scanner: scannerState.servers.map((server) => ({ ...server, - url: resolveDiscoveredServerUrl(input.environmentId, server.url, server.port), + url: resolveDiscoveredServerUrl( + input.environmentId, + server.url, + server.port, + server.urlKind, + ), requestedUrl: server.url, })), configuredUrls: input.configuredUrls ?? [], @@ -101,6 +106,7 @@ export function selectPreferredDiscoveredServer( servers: ReadonlyArray, ): DiscoveredLocalServer | null { const namedServer = servers.find((server) => { + if (server.urlKind !== undefined) return true; try { return new URL(server.url).hostname !== server.host; } catch { diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts index 24f429745ef8..70d547a45b0d 100644 --- a/packages/contracts/src/preview.test.ts +++ b/packages/contracts/src/preview.test.ts @@ -303,12 +303,14 @@ describe("DiscoveredLocalServer", () => { host: "localhost", port: 5173, url: "http://localhost:5173", + urlKind: "public-tunnel", processName: "node", pid: 12345, terminal: null, }); expect(server.port).toBe(5173); expect(server.processName).toBe("node"); + expect(server.urlKind).toBe("public-tunnel"); }); it("decodes a server without process metadata", () => { diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index 49998947dcf8..e3a4006b5e0c 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -292,10 +292,14 @@ export type PreviewEvent = typeof PreviewEvent.Type; * A local server detected by the port scanner. `url` may be a named local * proxy or public tunnel URL while `host` and `port` identify the underlying listener. */ +export const DiscoveredLocalServerUrlKind = Schema.Literals(["local-proxy", "public-tunnel"]); +export type DiscoveredLocalServerUrlKind = typeof DiscoveredLocalServerUrlKind.Type; + export const DiscoveredLocalServer = Schema.Struct({ host: TrimmedNonEmptyString, port: Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThan(65536)), url: Url, + urlKind: Schema.optional(DiscoveredLocalServerUrlKind), processName: Schema.NullOr(TrimmedNonEmptyString), pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), terminal: Schema.NullOr( From fdf6eb5eeb38cf667cee5f55d45e19f7b18aa931 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 26 Aug 2026 00:40:08 -0400 Subject: [PATCH 10/10] fix(preview): prefer ngrok server shortcuts --- apps/server/src/preview/PortScanner.test.ts | 9 +++++++ apps/server/src/preview/PortScanner.ts | 27 +++++++++++++++---- .../preview/useDiscoveredLocalServers.test.ts | 14 ++++++++++ .../preview/useDiscoveredLocalServers.ts | 11 +++++--- 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 3d308730247e..e2fc1a705f16 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -281,10 +281,19 @@ effectIt.effect("replaces a discovered listener with its ngrok public URL", () = return Effect.gen(function* () { const scanner = yield* PortScanner.PortDiscovery; + yield* scanner.registerTerminalProcesses({ + threadId: "thread-ngrok", + terminalId: "term-ngrok", + processIds: [5678], + }); const servers = yield* scanner.scan([configuredUrl]); expect(servers.map((server) => [server.port, server.url, server.urlKind])).toEqual([ [targetPort, "https://feature.ngrok-free.app/docs?mode=test#results", "public-tunnel"], ]); + expect(servers[0]?.terminal).toEqual({ + threadId: "thread-ngrok", + terminalId: "term-ngrok", + }); expect(requests).toContain("http://localhost:4040/api/tunnels"); expect(requests).toContain(configuredUrl); }).pipe(Effect.provide(layer)); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index f02308fa5bb9..82a76f37c910 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -116,6 +116,7 @@ interface PortlessRouteSnapshot { interface NamedRoute { readonly url: string; readonly urlKind: DiscoveredLocalServerUrlKind; + readonly terminal?: Exclude; } const parsePortlessRouteSnapshot = ( @@ -324,6 +325,7 @@ const projectWebProbeSnapshot = ( ...configured, url: namedUrl.href, urlKind: namedRoute.urlKind, + terminal: namedRoute.terminal, }); } for (const server of snapshot.discovered) { @@ -492,21 +494,31 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const readNgrokRoutes = Effect.fn("PortDiscovery.readNgrokRoutes")(function* ( servers: ReadonlyArray, ) { - const candidatesByPort = new Map(); + const candidatesByPort = new Map< + number, + { + readonly processNameIsNgrok: boolean; + readonly terminal: DiscoveredLocalServer["terminal"]; + } + >(); for (const server of servers) { const processNameIsNgrok = server.processName?.toLowerCase() === "ngrok"; if (server.port !== NGROK_DEFAULT_AGENT_API_PORT && !processNameIsNgrok) continue; - candidatesByPort.set(server.port, candidatesByPort.get(server.port) || processNameIsNgrok); + const current = candidatesByPort.get(server.port); + candidatesByPort.set(server.port, { + processNameIsNgrok: current?.processNameIsNgrok === true || processNameIsNgrok, + terminal: current?.terminal ?? server.terminal, + }); } const responses = yield* Effect.forEach( candidatesByPort, - ([port, processNameIsNgrok]) => + ([port, candidate]) => httpClient.get(`http://localhost:${port}/api/tunnels`).pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.flatMap((response) => response.json), Effect.map((body) => { const routes = parseNgrokTunnelSnapshot(body); - return routes === null ? null : { port, processNameIsNgrok, routes }; + return routes === null ? null : { port, ...candidate, routes }; }), Effect.scoped, Effect.timeoutOption(WEB_PROBE_TIMEOUT), @@ -520,7 +532,12 @@ export const make = Effect.gen(function* PortDiscoveryMake() { for (const response of responses) { if (response === null) continue; if (response.routes.size > 0 || response.processNameIsNgrok) agentPorts.add(response.port); - for (const [targetPort, route] of response.routes) routes.set(targetPort, route); + for (const [targetPort, route] of response.routes) { + routes.set( + targetPort, + response.terminal === null ? route : { ...route, terminal: response.terminal }, + ); + } } return { agentPorts, routes }; }); diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts index 0e91b62bbaf9..87622d81969d 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts @@ -188,6 +188,20 @@ describe("selectPreferredDiscoveredServer", () => { expect(selectPreferredDiscoveredServer([scannerServer({ port: 4004 }), ngrok])).toBe(ngrok); }); + it("prefers an ngrok tunnel over Portless regardless of scan order", () => { + const portless = scannerServer({ + port: 4004, + url: "https://artelo.localhost", + urlKind: "local-proxy", + }); + const ngrok = scannerServer({ + port: 4314, + url: "https://feature.ngrok-free.app/", + urlKind: "public-tunnel", + }); + expect(selectPreferredDiscoveredServer([portless, ngrok])).toBe(ngrok); + }); + it("falls back to the first listener when none has a named URL", () => { const first = scannerServer({ port: 3000, url: "http://localhost:3000" }); expect(selectPreferredDiscoveredServer([first, scannerServer({ port: 5173 })])).toBe(first); diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.ts index c805a6e354d9..0421070bc6b4 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.ts @@ -105,15 +105,20 @@ export function formatDiscoveredServerHost( export function selectPreferredDiscoveredServer( servers: ReadonlyArray, ): DiscoveredLocalServer | null { - const namedServer = servers.find((server) => { - if (server.urlKind !== undefined) return true; + const publicTunnel = servers.find((server) => server.urlKind === "public-tunnel"); + if (publicTunnel) return publicTunnel; + + const localProxy = servers.find((server) => server.urlKind === "local-proxy"); + if (localProxy) return localProxy; + + const legacyNamedServer = servers.find((server) => { try { return new URL(server.url).hostname !== server.host; } catch { return false; } }); - return namedServer ?? servers[0] ?? null; + return legacyNamedServer ?? servers[0] ?? null; } function parseLocalUrl(raw: string): { host: string; port: number; url: string } | null {