diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 7fa15defeca9..e2fc1a705f16 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -1,12 +1,13 @@ 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, 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"; @@ -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,8 @@ const makeProbeFailureLayer = ( PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( + NodeServices.layer, + Layer.succeed(HostProcessEnvironment, {}), Layer.succeed(ProcessRunner.ProcessRunner, { run }), Layer.succeed(Net.NetService, { canListenOnHost: () => Effect.succeed(true), @@ -76,6 +79,8 @@ const makeProbeFailureLayer = ( const TestPortDiscoveryLive = PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( + NodeServices.layer, + Layer.succeed(HostProcessEnvironment, {}), TestProcessRunner, TestIntegrationNet, Layer.succeed(HostProcessPlatform, "win32"), @@ -93,6 +98,8 @@ const makeLsofScannerLayer = (input: { PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( + NodeServices.layer, + Layer.succeed(HostProcessEnvironment, {}), Layer.succeed(ProcessRunner.ProcessRunner, { run: () => Effect.succeed({ @@ -121,6 +128,205 @@ 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)).toEqual({ + url: "https://eng-1252-simplify-the-onboarding.artelo.localhost", + urlKind: "local-proxy", + }); + expect( + PortScanner.__testing.applyNamedRoutes( + [ + { + 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", + urlKind: "local-proxy", + 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, { url: "http://current.test:8080", urlKind: "local-proxy" }], + ]); + }); + + 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, { url: "https://current.test", urlKind: "local-proxy" }]]); + }); +}); + +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, { url: "https://feature.ngrok-free.app/", urlKind: "public-tunnel" }], + ]); + }); + + 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, { url: "https://docs.example.com/", urlKind: "public-tunnel" }], + ]); + }); + + 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; + 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)); +}); + +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 4571aeef4c6b..82a76f37c910 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -21,8 +21,9 @@ import { PREVIEW_URL_MAX_LENGTH, ThreadId, type DiscoveredLocalServer, + type DiscoveredLocalServerUrlKind, } 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"; @@ -30,13 +31,16 @@ 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"; +import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ProcessRunner from "../processRunner.ts"; @@ -67,7 +71,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); @@ -77,6 +82,133 @@ 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, + 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; + +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; + readonly tls: boolean; + readonly isProcessAlive: (pid: number) => boolean; +} + +interface NamedRoute { + readonly url: string; + readonly urlKind: DiscoveredLocalServerUrlKind; + readonly terminal?: Exclude; +} + +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(input.proxyPortRaw?.trim() ?? ""); + const proxyPort = + Number.isInteger(parsedProxyPort) && parsedProxyPort > 0 && parsedProxyPort < 65536 + ? parsedProxyPort + : defaultProxyPort; + const protocol = input.tls ? "https" : "http"; + 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 (routesByTargetPort.has(entry.port)) continue; + + const portSuffix = proxyPort === defaultProxyPort ? "" : `:${proxyPort}`; + routesByTargetPort.set(entry.port, { + url: `${protocol}://${entry.hostname}${portSuffix}`, + urlKind: "local-proxy", + }); + } + + return routesByTargetPort; +}; + +const parseLoopbackTargetPort = (raw: string): number | null => { + 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 routesByTargetPort = 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 = routesByTargetPort.get(targetPort); + if ( + current === undefined || + (current.url.startsWith("http:") && publicUrl.protocol === "https:") + ) { + routesByTargetPort.set(targetPort, { + url: publicUrl.href, + urlKind: "public-tunnel", + }); + } + } + return routesByTargetPort; +}; + +const applyNamedRoutes = ( + servers: ReadonlyArray, + routesByTargetPort: ReadonlyMap, +): ReadonlyArray => + servers.map((server) => { + const route = routesByTargetPort.get(server.port); + return route === undefined ? server : { ...server, ...route }; + }); type Listener = (servers: ReadonlyArray) => Effect.Effect; @@ -167,13 +299,34 @@ const projectWebProbeSnapshot = ( configuredUrls: ReadonlyArray, ): ReadonlyArray => { const visibleByServer = new Map(); + const namedRouteByServer = new Map(); + for (const server of snapshot.discovered) { + if (server.urlKind !== undefined) { + 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, + urlKind: namedRoute.urlKind, + terminal: namedRoute.terminal, + }); } for (const server of snapshot.discovered) { const key = localServerKey(server.host, server.port); @@ -278,6 +431,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 || @@ -292,8 +446,11 @@ 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; + const path = yield* Path.Path; const stateRef = yield* Ref.make({ listeners: new Map(), terminalProcesses: new Map(), @@ -302,6 +459,89 @@ 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 = 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); + 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 readNgrokRoutes = Effect.fn("PortDiscovery.readNgrokRoutes")(function* ( + servers: ReadonlyArray, + ) { + 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; + 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, 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, ...candidate, 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; + if (response.routes.size > 0 || response.processNameIsNgrok) agentPorts.add(response.port); + for (const [targetPort, route] of response.routes) { + routes.set( + targetPort, + response.terminal === null ? route : { ...route, terminal: response.terminal }, + ); + } + } + return { agentPorts, routes }; + }); + const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { const results = yield* Effect.forEach( COMMON_DEV_PORTS, @@ -469,6 +709,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, route] of ngrok.routes) namedRoutes.set(targetPort, route); + 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", { @@ -509,8 +766,10 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ProcessTimeoutError: recoverWindowsProbeFailure, }), ); - if (listeners !== null) return yield* probeWebServers(listeners, configuredUrls); - return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); + return yield* probeAndEnrichWebServers( + listeners ?? (yield* probeCommonPorts()), + configuredUrls, + ); } const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); const lsofResult = yield* processRunner @@ -531,8 +790,10 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ProcessTimeoutError: recoverLsofProbeFailure, }), ); - if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls); - return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); + return yield* probeAndEnrichWebServers( + lsofResult ?? (yield* probeCommonPorts()), + configuredUrls, + ); }); const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")( @@ -662,3 +923,10 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }).pipe(Effect.withSpan("PortDiscovery.make")); export const layer = Layer.effect(PortDiscovery, make); + +export const __testing = { + applyNamedRoutes, + parseLoopbackTargetPort, + parseNgrokTunnelSnapshot, + parsePortlessRouteSnapshot, +}; diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index cbce157f9a05..b7a0c7122e7f 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -144,6 +144,44 @@ 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://current.test/dashboard?mode=test#results", + 4058, + "local-proxy", + ), + ).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, + "public-tunnel", + ), + ).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/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 684247e28022..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"; @@ -241,9 +242,36 @@ 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, + urlKind?: DiscoveredLocalServerUrlKind, +): string { try { const normalizedUrl = normalizePreviewUrl(rawUrl); + const parsed = new URL(normalizedUrl); + 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( + 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/PreviewEmptyState.test.tsx b/apps/web/src/components/preview/PreviewEmptyState.test.tsx index 0f9da0fdb3bd..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; @@ -15,7 +16,8 @@ const mocks = vi.hoisted(() => ({ }>, })); -vi.mock("./useDiscoveredLocalServers", () => ({ +vi.mock("./useDiscoveredLocalServers", async (importOriginal) => ({ + ...(await importOriginal()), useDiscoveredLocalServers: () => mocks.servers, })); vi.mock("./PreviewFaviconIcon", () => ({ @@ -74,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 163849154000..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,6 +10,7 @@ import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui import { PreviewLocalServerCard } from "./PreviewLocalServerCard"; import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard"; +import { findDiscoveredServerTarget } from "./previewEmptyStateLogic"; import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers"; interface Props { @@ -14,7 +19,7 @@ interface Props { configuredUrls?: ReadonlyArray | undefined; recentEntries: ReadonlyArray; onRemoveRecent: (url: string) => void; - onOpenUrl: (url: string) => void; + onOpenUrl: (url: string, targetPort?: number, urlKind?: DiscoveredLocalServerUrlKind) => void; } export function PreviewEmptyState({ @@ -61,7 +66,10 @@ export function PreviewEmptyState({ key={entry.url} threadRef={threadRef} entry={entry} - onOpen={() => onOpenUrl(entry.url)} + onOpen={() => { + const target = findDiscoveredServerTarget(entry.url, servers); + onOpenUrl(entry.url, target?.port, target?.urlKind); + }} onRemove={() => onRemoveRecent(entry.url)} /> ))} @@ -80,7 +88,7 @@ export function PreviewEmptyState({ key={`${server.host}:${server.port}`} threadRef={threadRef} server={server} - onOpen={() => onOpenUrl(server.requestedUrl)} + 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 263cdb294f48..84d5b64819f3 100644 --- a/apps/web/src/components/preview/PreviewLocalServerCard.tsx +++ b/apps/web/src/components/preview/PreviewLocalServerCard.tsx @@ -1,7 +1,7 @@ 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; @@ -21,7 +21,7 @@ export function PreviewLocalServerCard({ threadRef, server, onOpen }: Props) {
{subtitle} - {server.host}:{server.port} + {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 2ace29ae5c41..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) => 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) => void }) => { + PreviewEmptyState: (props: { + onOpenUrl: (url: string, targetPort?: number, urlKind?: DiscoveredLocalServerUrlKind) => void; + }) => { mocks.emptyStateUrl = props.onOpenUrl; return null; }, @@ -465,6 +470,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://current.test/", 4058, "local-proxy"); + + 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 9a812923717a..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) => { + async (next: string, targetPort?: number, urlKind?: DiscoveredLocalServerUrlKind) => { try { - const resolved = resolveDiscoveredServerUrl(threadRef.environmentId, next); + 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) => void handleOpenServerUrl(next)} + 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 a49acbd86104..e17e07a1b245 100644 --- a/apps/web/src/components/preview/openDiscoveredPort.ts +++ b/apps/web/src/components/preview/openDiscoveredPort.ts @@ -15,7 +15,12 @@ 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, + input.port.urlKind, + ); const result = await openPreviewSession({ openPreview: input.openPreview, threadRef: input.threadRef, diff --git a/apps/web/src/components/preview/previewEmptyStateLogic.test.ts b/apps/web/src/components/preview/previewEmptyStateLogic.test.ts index 3759173d3cc2..d65b143a5d8d 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 { + findDiscoveredServerTarget, + getConfiguredPreviewUrls, + shouldShowPreviewEmptyState, +} from "./previewEmptyStateLogic"; +import type { PreviewableServer } from "./useDiscoveredLocalServers"; const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessionSnapshot => ({ threadId: "thread-1", @@ -40,3 +45,24 @@ describe("getConfiguredPreviewUrls", () => { ]); }); }); + +describe("findDiscoveredServerTarget", () => { + 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", + urlKind: "local-proxy", + } satisfies PreviewableServer; + + expect( + 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 1ebd074032b0..8ff6c24c77c5 100644 --- a/apps/web/src/components/preview/previewEmptyStateLogic.ts +++ b/apps/web/src/components/preview/previewEmptyStateLogic.ts @@ -1,5 +1,9 @@ import type { PreviewSessionSnapshot, ProjectScript } from "@t3tools/contracts"; +import { normalizeHistoryUrl } from "~/browserHistoryStore"; + +import type { PreviewableServer } from "./useDiscoveredLocalServers"; + export function shouldShowPreviewEmptyState(snapshot: PreviewSessionSnapshot | null): boolean { return snapshot === null || snapshot.navStatus._tag === "Idle"; } @@ -9,3 +13,17 @@ export function getConfiguredPreviewUrls( ): ReadonlyArray { return scripts?.flatMap((script) => (script.previewUrl ? [script.previewUrl] : [])) ?? []; } + +export function findDiscoveredServerTarget( + url: string, + servers: ReadonlyArray, +): { readonly port: number; readonly urlKind: PreviewableServer["urlKind"] } | undefined { + const normalizedUrl = normalizeHistoryUrl(url); + if (normalizedUrl === null) return undefined; + const origin = new URL(normalizedUrl).origin; + const server = servers.find((candidate) => { + const normalizedRequestedUrl = normalizeHistoryUrl(candidate.requestedUrl); + return normalizedRequestedUrl !== null && new URL(normalizedRequestedUrl).origin === origin; + }); + 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 ba1846902324..87622d81969d 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,62 @@ 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("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"); + }); +}); + +describe("selectPreferredDiscoveredServer", () => { + it("prefers a named proxy over another listener from the same terminal", () => { + 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/", + urlKind: "public-tunnel", + }); + 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 c2907a5b6a6d..0421070bc6b4 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), + url: resolveDiscoveredServerUrl( + input.environmentId, + server.url, + server.port, + server.urlKind, + ), requestedUrl: server.url, })), configuredUrls: input.configuredUrls ?? [], @@ -87,6 +92,35 @@ 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 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 legacyNamedServer ?? 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..e94f86c5339e 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 active ngrok HTTP tunnels, then +opens their named or public URL instead of the underlying `localhost` port. 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 a1b743afc673..e3a4006b5e0c 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -289,13 +289,17 @@ 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 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(