/** * In-process PortScanner implementation. * * macOS/Linux: parses `lsof -iTCP -sTCP:LISTEN -P -n -F pcn` (-F output is a * stable line-prefixed field format; this is the only `lsof` flag set we rely * on). * * Windows / lsof missing: checks a curated list of common dev ports through * the shared Net service. * * Listening ports are published only after a bounded HTTP(S) probe finds a * successful HTML document or a redirect to one. * Positive and negative results are cached briefly by candidate URL and listener identity, * limiting repeated requests without leaving stale classifications around. * * 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 { CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS, PREVIEW_URL_MAX_LENGTH, ThreadId, type DiscoveredLocalServer,} from "@t3tools/contracts";import { 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";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 Layer from "effect/Layer";import * as Option from "effect/Option";import * as Ref from "effect/Ref";import * as Schedule from "effect/Schedule";import * as Scope from "effect/Scope";import * as Semaphore from "effect/Semaphore";import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import * as ProcessRunner from "../processRunner.ts"; export class PortDiscovery extends Context.Service< PortDiscovery, { readonly scan: ( configuredUrls?: ReadonlyArray<string>, ) => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>; readonly subscribe: ( input: { readonly configuredUrls: ReadonlyArray<string>; readonly initialSnapshot: ReadonlyArray<DiscoveredLocalServer>; }, listener: (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>, ) => Effect.Effect<void, never, Scope.Scope>; readonly retain: Effect.Effect<void, never, Scope.Scope>; readonly registerTerminalProcesses: (input: { readonly threadId: string; readonly terminalId: string; readonly processIds: ReadonlyArray<number>; }) => Effect.Effect<void>; readonly unregisterTerminal: (input: { readonly threadId: string; readonly terminalId: string; }) => Effect.Effect<void>; }>()("t3/preview/PortScanner/PortDiscovery") {} export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([ 3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000,]); const POLL_INTERVAL = Duration.seconds(3);const LSOF_TIMEOUT_MS = 5_000;const WINDOWS_LISTENER_TIMEOUT_MS = 5_000;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]); type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>; interface ListenerSubscription { readonly configuredUrls: ReadonlyArray<string>; readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>;} interface ScannerState { readonly listeners: ReadonlyMap<Listener, ListenerSubscription>; readonly terminalProcesses: ReadonlyMap< string, { readonly owner: TerminalProcessOwner; readonly processIds: ReadonlySet<number>; } >; readonly retainCount: number;} interface TerminalProcessOwner { readonly threadId: ThreadId; readonly terminalId: string;} interface WebProbeCacheEntry { readonly pid: number | null; readonly isWeb: boolean; readonly expiresAtMillis: number;} interface WebProbeGroup { readonly server: DiscoveredLocalServer; readonly urls: ReadonlyArray<string>; readonly configuredKey: string | null;} interface WebProbeSnapshot { readonly discovered: ReadonlyArray<DiscoveredLocalServer>; readonly configured: ReadonlyMap<string, DiscoveredLocalServer>;} const terminalOwnerKey = (owner: { readonly threadId: string; readonly terminalId: string;}): string => `${owner.threadId}\u0000${owner.terminalId}`; const parseConfiguredUrl = (raw: string): URL | null => { try { const url = new URL(raw); if (url.protocol !== "http:" && url.protocol !== "https:") return null; if (!isLoopbackHost(url.hostname)) return null; return url; } catch { return null; }}; const localServerKey = (host: string, port: number): string => `${isLoopbackHost(host) ? "loopback" : host.toLowerCase()}:${port}`; const urlPort = (url: URL): number => url.port.length > 0 ? Number.parseInt(url.port, 10) : url.protocol === "http:" ? 80 : 443; const webProbeCacheKey = (raw: string): string => { const url = new URL(raw); url.hash = ""; return url.href;}; const normalizeConfiguredUrls = (urls: ReadonlyArray<string>): ReadonlyArray<string> => [ ...new Set( urls .slice(0, CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS) .filter((raw) => raw.length <= PREVIEW_URL_MAX_LENGTH) .map(parseConfiguredUrl) .filter((url): url is URL => url !== null && url.href.length <= PREVIEW_URL_MAX_LENGTH) .map((url) => { if (url.hostname === "0.0.0.0") url.hostname = "localhost"; return url.href; }) .filter((url) => url.length <= PREVIEW_URL_MAX_LENGTH), ),]; const projectWebProbeSnapshot = ( snapshot: WebProbeSnapshot, configuredUrls: ReadonlyArray<string>,): ReadonlyArray<DiscoveredLocalServer> => { const visibleByServer = new Map<string, DiscoveredLocalServer>(); 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 }); } for (const server of snapshot.discovered) { const key = localServerKey(server.host, server.port); if (!visibleByServer.has(key)) visibleByServer.set(key, server); } return [...visibleByServer.values()].toSorted((left, right) => left.port - right.port);}; const parseLsofOutput = ( raw: string, terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(),): ReadonlyArray<DiscoveredLocalServer> => { const seen = new Map<string, DiscoveredLocalServer>(); let pid: number | null = null; let processName: string | null = null; for (const line of raw.split("\n")) { if (line.length === 0) continue; const tag = line.charAt(0); const value = line.slice(1); if (tag === "p") { const parsed = Number.parseInt(value, 10); pid = Number.isFinite(parsed) && parsed > 0 ? parsed : null; processName = null; continue; } if (tag === "c") { processName = value.trim() || null; continue; } if (tag === "n") { const portMatch = parsePortFromLsofName(value); if (portMatch == null) continue; const url = `http://localhost:${portMatch}`; const key = `localhost:${portMatch}`; if (seen.has(key)) continue; seen.set(key, { host: "localhost", port: portMatch, url, processName, pid, terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null), }); } } return Array.from(seen.values()).toSorted((a, b) => a.port - b.port);}; const parsePortFromLsofName = (name: string): number | null => { // Examples: "*:5173", "127.0.0.1:5173", "[::1]:5173", "localhost:5173", // "192.168.1.10:5173 (LISTEN)" — we only care if the host part is local. const trimmed = name.split(" ", 1)[0]?.trim() ?? ""; if (trimmed.length === 0) return null; const lastColon = trimmed.lastIndexOf(":"); if (lastColon < 0) return null; const hostPart = trimmed.slice(0, lastColon); const portPart = trimmed.slice(lastColon + 1); if (!LSOF_LOCAL_HOST_TOKENS.has(hostPart)) return null; const port = Number.parseInt(portPart, 10); if (!Number.isFinite(port) || port <= 0 || port >= 65536) return null; return port;}; const parseWindowsListenerOutput = ( raw: string, terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(),): ReadonlyArray<DiscoveredLocalServer> => { const seen = new Map<number, DiscoveredLocalServer>(); for (const line of raw.split(/\r?\n/g)) { const [hostRaw, portRaw, pidRaw, processNameRaw] = line.trim().split("|", 4); const host = hostRaw?.trim() ?? ""; if (!LSOF_LOCAL_HOST_TOKENS.has(host) && host !== "::") continue; const port = Number(portRaw); const pid = Number(pidRaw); if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue; const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null; if (seen.has(port)) continue; seen.set(port, { host: "localhost", port, url: `http://localhost:${port}`, processName: processNameRaw?.trim() || null, pid: normalizedPid, terminal: normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null), }); } return [...seen.values()].toSorted((left, right) => left.port - right.port);}; const serversEqual = ( left: ReadonlyArray<DiscoveredLocalServer>, right: ReadonlyArray<DiscoveredLocalServer>,): boolean => { if (left.length !== right.length) return false; for (let i = 0; i < left.length; i += 1) { const a = left[i]; const b = right[i]; if (!a || !b) return false; if ( a.host !== b.host || a.port !== b.port || a.url !== b.url || a.processName !== b.processName || a.pid !== b.pid || a.terminal?.threadId !== b.terminal?.threadId || a.terminal?.terminalId !== b.terminal?.terminalId ) { return false; } } return true;}; /** @public Service construction is part of the canonical Effect module API. */export const make = Effect.gen(function* PortDiscoveryMake() { const net = yield* Net.NetService; const processRunner = yield* ProcessRunner.ProcessRunner; const hostPlatform = yield* HostProcessPlatform; const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); const stateRef = yield* Ref.make<ScannerState>({ listeners: new Map(), terminalProcesses: new Map(), retainCount: 0, }); const webProbeCacheRef = yield* Ref.make<ReadonlyMap<string, WebProbeCacheEntry>>(new Map()); const scanSemaphore = yield* Semaphore.make(1); const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { const results = yield* Effect.forEach( COMMON_DEV_PORTS, (port) => net.isPortAvailableOnLoopback(port).pipe( Effect.map((available) => ({ port, listening: !available, })), ), { concurrency: "unbounded" }, ); return results .filter((result) => result.listening) .map<DiscoveredLocalServer>((result) => ({ host: "localhost", port: result.port, url: `http://localhost:${result.port}`, processName: null, pid: null, terminal: null, })); }); const probeWebUrl = Effect.fn("PortDiscovery.probeWebUrl")((url: string) => httpClient.get(url).pipe( Effect.map((response) => { const location = response.headers.location?.trim(); if (NAVIGATION_REDIRECT_STATUSES.has(response.status) && location) return url; if (response.status < 200 || response.status >= 300) return null; if (response.status === 204 || response.status === 205) return null; const contentType = response.headers["content-type"] ?.split(";", 1)[0] ?.trim() .toLowerCase(); return contentType === "text/html" || contentType === "application/xhtml+xml" ? url : null; }), Effect.scoped, Effect.timeoutOption(WEB_PROBE_TIMEOUT), Effect.map(Option.getOrNull), Effect.orElseSucceed(() => null), Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), ), ); const makeWebProbeGroups = ( servers: ReadonlyArray<DiscoveredLocalServer>, configuredUrls: ReadonlyArray<string>, ): ReadonlyArray<WebProbeGroup> => { const serversByKey = new Map( servers.map((server) => [localServerKey(server.host, server.port), server] as const), ); const groups: WebProbeGroup[] = []; const configuredResources = new Set<string>(); for (const raw of configuredUrls) { const url = new URL(raw); const port = urlPort(url); const key = localServerKey(url.hostname, port); const resourceKey = webProbeCacheKey(raw); if (configuredResources.has(resourceKey)) continue; configuredResources.add(resourceKey); groups.push({ server: serversByKey.get(key) ?? { host: url.hostname, port, url: raw, processName: null, pid: null, terminal: null, }, urls: [raw], configuredKey: resourceKey, }); } for (const server of servers) { groups.push({ server, urls: [`http://${server.host}:${server.port}`, `https://${server.host}:${server.port}`], configuredKey: null, }); } return groups; }; const probeWebServers = Effect.fn("PortDiscovery.probeWebServers")(function* ( servers: ReadonlyArray<DiscoveredLocalServer>, configuredUrls: ReadonlyArray<string>, ) { const nowMillis = yield* Clock.currentTimeMillis; const cached = yield* Ref.get(webProbeCacheRef); const groups = makeWebProbeGroups(servers, configuredUrls); const batchProbes = new Map< string, Effect.Effect<{ readonly probe: WebProbeCacheEntry; readonly fresh: boolean }> >(); const batchProbeSemaphore = yield* Semaphore.make(1); const getProbe = (url: string, pid: number | null) => { const key = webProbeCacheKey(url); const identity = `${key}\u0000${pid ?? ""}`; return batchProbeSemaphore .withPermits(1)( Effect.gen(function* () { const existing = batchProbes.get(identity); if (existing) return [existing] as const; const cachedProbe = cached.get(key); const cachedIsCurrent = cachedProbe?.pid === pid && cachedProbe.expiresAtMillis > nowMillis; const memoized = yield* Effect.cached( cachedIsCurrent ? Effect.succeed({ probe: cachedProbe, fresh: false }) : probeWebUrl(url).pipe( Effect.map((result) => ({ probe: { pid, isWeb: result !== null, expiresAtMillis: 0 }, fresh: true, })), ), ); batchProbes.set(identity, memoized); return [memoized] as const; }), ) .pipe(Effect.flatMap(([probe]) => probe)); }; const probed = yield* Effect.forEach( groups, (group) => Effect.gen(function* () { const probes: Array<readonly [string, WebProbeCacheEntry, boolean]> = []; let visibleUrl: string | null = null; for (const url of group.urls) { const key = webProbeCacheKey(url); const { probe, fresh } = yield* getProbe(url, group.server.pid); probes.push([key, probe, fresh]); if (probe.isWeb) { visibleUrl = url; break; } } return { group, probes, visibleUrl }; }), { concurrency: WEB_PROBE_CONCURRENCY }, ); const completedAtMillis = yield* Clock.currentTimeMillis; const nextCache = new Map( [...cached].filter(([, probe]) => probe.expiresAtMillis > completedAtMillis), ); const discovered: DiscoveredLocalServer[] = []; const configured = new Map<string, DiscoveredLocalServer>(); for (const { group, probes, visibleUrl } of probed) { for (const [key, probe, fresh] of probes) { nextCache.set( key, fresh ? { ...probe, expiresAtMillis: completedAtMillis + WEB_PROBE_CACHE_TTL_MS } : probe, ); } if (visibleUrl === null) continue; const server = { ...group.server, url: visibleUrl }; if (group.configuredKey === null) discovered.push(server); else configured.set(group.configuredKey, server); } yield* Ref.set(webProbeCacheRef, nextCache); return { discovered, configured } satisfies WebProbeSnapshot; }); const recoverProcessProbeFailure = (probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) => Effect.logDebug("preview port process probe failed; falling back to common-port probes", { cause: error, probe, platform: hostPlatform, }).pipe(Effect.as(null)); const scanUnlocked = Effect.fn("PortDiscovery.scanUnlocked")(function* ( configuredUrls: ReadonlyArray<string>, ) { const state = yield* Ref.get(stateRef); const terminalByProcessId = new Map<number, TerminalProcessOwner>(); for (const registration of state.terminalProcesses.values()) { for (const processId of registration.processIds) { terminalByProcessId.set(processId, registration.owner); } } if (hostPlatform === "win32") { const recoverWindowsProbeFailure = recoverProcessProbeFailure("windows-listeners"); const command = 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; const listeners = yield* processRunner .run({ command: "powershell.exe", args: ["-NoProfile", "-NonInteractive", "-Command", command], timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), maxOutputBytes: 1024 * 1024,Lines 1–500 of 665. Load more
/** * In-process PortScanner implementation. * * macOS/Linux: parses `lsof -iTCP -sTCP:LISTEN -P -n -F pcn` (-F output is a * stable line-prefixed field format; this is the only `lsof` flag set we rely * on). * * Windows / lsof missing: checks a curated list of common dev ports through * the shared Net service. * * Listening ports are published only after a bounded HTTP(S) probe finds a * successful HTML document or a redirect to one. * Positive and negative results are cached briefly by candidate URL and listener identity, * limiting repeated requests without leaving stale classifications around. * * 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 { CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS, PREVIEW_URL_MAX_LENGTH, ThreadId, type DiscoveredLocalServer, } from "@t3tools/contracts"; import { 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"; 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 Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import * as ProcessRunner from "../processRunner.ts"; export class PortDiscovery extends Context.Service< PortDiscovery, { readonly scan: ( configuredUrls?: ReadonlyArray<string>, ) => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>; readonly subscribe: ( input: { readonly configuredUrls: ReadonlyArray<string>; readonly initialSnapshot: ReadonlyArray<DiscoveredLocalServer>; }, listener: (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>, ) => Effect.Effect<void, never, Scope.Scope>; readonly retain: Effect.Effect<void, never, Scope.Scope>; readonly registerTerminalProcesses: (input: { readonly threadId: string; readonly terminalId: string; readonly processIds: ReadonlyArray<number>; }) => Effect.Effect<void>; readonly unregisterTerminal: (input: { readonly threadId: string; readonly terminalId: string; }) => Effect.Effect<void>; } >()("t3/preview/PortScanner/PortDiscovery") {} export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([ 3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000, ]); const POLL_INTERVAL = Duration.seconds(3); const LSOF_TIMEOUT_MS = 5_000; const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; 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]); type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>; interface ListenerSubscription { readonly configuredUrls: ReadonlyArray<string>; readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>; } interface ScannerState { readonly listeners: ReadonlyMap<Listener, ListenerSubscription>; readonly terminalProcesses: ReadonlyMap< string, { readonly owner: TerminalProcessOwner; readonly processIds: ReadonlySet<number>; } >; readonly retainCount: number; } interface TerminalProcessOwner { readonly threadId: ThreadId; readonly terminalId: string; } interface WebProbeCacheEntry { readonly pid: number | null; readonly isWeb: boolean; readonly expiresAtMillis: number; } interface WebProbeGroup { readonly server: DiscoveredLocalServer; readonly urls: ReadonlyArray<string>; readonly configuredKey: string | null; } interface WebProbeSnapshot { readonly discovered: ReadonlyArray<DiscoveredLocalServer>; readonly configured: ReadonlyMap<string, DiscoveredLocalServer>; } const terminalOwnerKey = (owner: { readonly threadId: string; readonly terminalId: string; }): string => `${owner.threadId}\u0000${owner.terminalId}`; const parseConfiguredUrl = (raw: string): URL | null => { try { const url = new URL(raw); if (url.protocol !== "http:" && url.protocol !== "https:") return null; if (!isLoopbackHost(url.hostname)) return null; return url; } catch { return null; } }; const localServerKey = (host: string, port: number): string => `${isLoopbackHost(host) ? "loopback" : host.toLowerCase()}:${port}`; const urlPort = (url: URL): number => url.port.length > 0 ? Number.parseInt(url.port, 10) : url.protocol === "http:" ? 80 : 443; const webProbeCacheKey = (raw: string): string => { const url = new URL(raw); url.hash = ""; return url.href; }; const normalizeConfiguredUrls = (urls: ReadonlyArray<string>): ReadonlyArray<string> => [ ...new Set( urls .slice(0, CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS) .filter((raw) => raw.length <= PREVIEW_URL_MAX_LENGTH) .map(parseConfiguredUrl) .filter((url): url is URL => url !== null && url.href.length <= PREVIEW_URL_MAX_LENGTH) .map((url) => { if (url.hostname === "0.0.0.0") url.hostname = "localhost"; return url.href; }) .filter((url) => url.length <= PREVIEW_URL_MAX_LENGTH), ), ]; const projectWebProbeSnapshot = ( snapshot: WebProbeSnapshot, configuredUrls: ReadonlyArray<string>, ): ReadonlyArray<DiscoveredLocalServer> => { const visibleByServer = new Map<string, DiscoveredLocalServer>(); 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 }); } for (const server of snapshot.discovered) { const key = localServerKey(server.host, server.port); if (!visibleByServer.has(key)) visibleByServer.set(key, server); } return [...visibleByServer.values()].toSorted((left, right) => left.port - right.port); }; const parseLsofOutput = ( raw: string, terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(), ): ReadonlyArray<DiscoveredLocalServer> => { const seen = new Map<string, DiscoveredLocalServer>(); let pid: number | null = null; let processName: string | null = null; for (const line of raw.split("\n")) { if (line.length === 0) continue; const tag = line.charAt(0); const value = line.slice(1); if (tag === "p") { const parsed = Number.parseInt(value, 10); pid = Number.isFinite(parsed) && parsed > 0 ? parsed : null; processName = null; continue; } if (tag === "c") { processName = value.trim() || null; continue; } if (tag === "n") { const portMatch = parsePortFromLsofName(value); if (portMatch == null) continue; const url = `http://localhost:${portMatch}`; const key = `localhost:${portMatch}`; if (seen.has(key)) continue; seen.set(key, { host: "localhost", port: portMatch, url, processName, pid, terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null), }); } } return Array.from(seen.values()).toSorted((a, b) => a.port - b.port); }; const parsePortFromLsofName = (name: string): number | null => { // Examples: "*:5173", "127.0.0.1:5173", "[::1]:5173", "localhost:5173", // "192.168.1.10:5173 (LISTEN)" — we only care if the host part is local. const trimmed = name.split(" ", 1)[0]?.trim() ?? ""; if (trimmed.length === 0) return null; const lastColon = trimmed.lastIndexOf(":"); if (lastColon < 0) return null; const hostPart = trimmed.slice(0, lastColon); const portPart = trimmed.slice(lastColon + 1); if (!LSOF_LOCAL_HOST_TOKENS.has(hostPart)) return null; const port = Number.parseInt(portPart, 10); if (!Number.isFinite(port) || port <= 0 || port >= 65536) return null; return port; }; const parseWindowsListenerOutput = ( raw: string, terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(), ): ReadonlyArray<DiscoveredLocalServer> => { const seen = new Map<number, DiscoveredLocalServer>(); for (const line of raw.split(/\r?\n/g)) { const [hostRaw, portRaw, pidRaw, processNameRaw] = line.trim().split("|", 4); const host = hostRaw?.trim() ?? ""; if (!LSOF_LOCAL_HOST_TOKENS.has(host) && host !== "::") continue; const port = Number(portRaw); const pid = Number(pidRaw); if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue; const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null; if (seen.has(port)) continue; seen.set(port, { host: "localhost", port, url: `http://localhost:${port}`, processName: processNameRaw?.trim() || null, pid: normalizedPid, terminal: normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null), }); } return [...seen.values()].toSorted((left, right) => left.port - right.port); }; const serversEqual = ( left: ReadonlyArray<DiscoveredLocalServer>, right: ReadonlyArray<DiscoveredLocalServer>, ): boolean => { if (left.length !== right.length) return false; for (let i = 0; i < left.length; i += 1) { const a = left[i]; const b = right[i]; if (!a || !b) return false; if ( a.host !== b.host || a.port !== b.port || a.url !== b.url || a.processName !== b.processName || a.pid !== b.pid || a.terminal?.threadId !== b.terminal?.threadId || a.terminal?.terminalId !== b.terminal?.terminalId ) { return false; } } return true; }; /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* PortDiscoveryMake() { const net = yield* Net.NetService; const processRunner = yield* ProcessRunner.ProcessRunner; const hostPlatform = yield* HostProcessPlatform; const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); const stateRef = yield* Ref.make<ScannerState>({ listeners: new Map(), terminalProcesses: new Map(), retainCount: 0, }); const webProbeCacheRef = yield* Ref.make<ReadonlyMap<string, WebProbeCacheEntry>>(new Map()); const scanSemaphore = yield* Semaphore.make(1); const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { const results = yield* Effect.forEach( COMMON_DEV_PORTS, (port) => net.isPortAvailableOnLoopback(port).pipe( Effect.map((available) => ({ port, listening: !available, })), ), { concurrency: "unbounded" }, ); return results .filter((result) => result.listening) .map<DiscoveredLocalServer>((result) => ({ host: "localhost", port: result.port, url: `http://localhost:${result.port}`, processName: null, pid: null, terminal: null, })); }); const probeWebUrl = Effect.fn("PortDiscovery.probeWebUrl")((url: string) => httpClient.get(url).pipe( Effect.map((response) => { const location = response.headers.location?.trim(); if (NAVIGATION_REDIRECT_STATUSES.has(response.status) && location) return url; if (response.status < 200 || response.status >= 300) return null; if (response.status === 204 || response.status === 205) return null; const contentType = response.headers["content-type"] ?.split(";", 1)[0] ?.trim() .toLowerCase(); return contentType === "text/html" || contentType === "application/xhtml+xml" ? url : null; }), Effect.scoped, Effect.timeoutOption(WEB_PROBE_TIMEOUT), Effect.map(Option.getOrNull), Effect.orElseSucceed(() => null), Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), ), ); const makeWebProbeGroups = ( servers: ReadonlyArray<DiscoveredLocalServer>, configuredUrls: ReadonlyArray<string>, ): ReadonlyArray<WebProbeGroup> => { const serversByKey = new Map( servers.map((server) => [localServerKey(server.host, server.port), server] as const), ); const groups: WebProbeGroup[] = []; const configuredResources = new Set<string>(); for (const raw of configuredUrls) { const url = new URL(raw); const port = urlPort(url); const key = localServerKey(url.hostname, port); const resourceKey = webProbeCacheKey(raw); if (configuredResources.has(resourceKey)) continue; configuredResources.add(resourceKey); groups.push({ server: serversByKey.get(key) ?? { host: url.hostname, port, url: raw, processName: null, pid: null, terminal: null, }, urls: [raw], configuredKey: resourceKey, }); } for (const server of servers) { groups.push({ server, urls: [`http://${server.host}:${server.port}`, `https://${server.host}:${server.port}`], configuredKey: null, }); } return groups; }; const probeWebServers = Effect.fn("PortDiscovery.probeWebServers")(function* ( servers: ReadonlyArray<DiscoveredLocalServer>, configuredUrls: ReadonlyArray<string>, ) { const nowMillis = yield* Clock.currentTimeMillis; const cached = yield* Ref.get(webProbeCacheRef); const groups = makeWebProbeGroups(servers, configuredUrls); const batchProbes = new Map< string, Effect.Effect<{ readonly probe: WebProbeCacheEntry; readonly fresh: boolean }> >(); const batchProbeSemaphore = yield* Semaphore.make(1); const getProbe = (url: string, pid: number | null) => { const key = webProbeCacheKey(url); const identity = `${key}\u0000${pid ?? ""}`; return batchProbeSemaphore .withPermits(1)( Effect.gen(function* () { const existing = batchProbes.get(identity); if (existing) return [existing] as const; const cachedProbe = cached.get(key); const cachedIsCurrent = cachedProbe?.pid === pid && cachedProbe.expiresAtMillis > nowMillis; const memoized = yield* Effect.cached( cachedIsCurrent ? Effect.succeed({ probe: cachedProbe, fresh: false }) : probeWebUrl(url).pipe( Effect.map((result) => ({ probe: { pid, isWeb: result !== null, expiresAtMillis: 0 }, fresh: true, })), ), ); batchProbes.set(identity, memoized); return [memoized] as const; }), ) .pipe(Effect.flatMap(([probe]) => probe)); }; const probed = yield* Effect.forEach( groups, (group) => Effect.gen(function* () { const probes: Array<readonly [string, WebProbeCacheEntry, boolean]> = []; let visibleUrl: string | null = null; for (const url of group.urls) { const key = webProbeCacheKey(url); const { probe, fresh } = yield* getProbe(url, group.server.pid); probes.push([key, probe, fresh]); if (probe.isWeb) { visibleUrl = url; break; } } return { group, probes, visibleUrl }; }), { concurrency: WEB_PROBE_CONCURRENCY }, ); const completedAtMillis = yield* Clock.currentTimeMillis; const nextCache = new Map( [...cached].filter(([, probe]) => probe.expiresAtMillis > completedAtMillis), ); const discovered: DiscoveredLocalServer[] = []; const configured = new Map<string, DiscoveredLocalServer>(); for (const { group, probes, visibleUrl } of probed) { for (const [key, probe, fresh] of probes) { nextCache.set( key, fresh ? { ...probe, expiresAtMillis: completedAtMillis + WEB_PROBE_CACHE_TTL_MS } : probe, ); } if (visibleUrl === null) continue; const server = { ...group.server, url: visibleUrl }; if (group.configuredKey === null) discovered.push(server); else configured.set(group.configuredKey, server); } yield* Ref.set(webProbeCacheRef, nextCache); return { discovered, configured } satisfies WebProbeSnapshot; }); const recoverProcessProbeFailure = (probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) => Effect.logDebug("preview port process probe failed; falling back to common-port probes", { cause: error, probe, platform: hostPlatform, }).pipe(Effect.as(null)); const scanUnlocked = Effect.fn("PortDiscovery.scanUnlocked")(function* ( configuredUrls: ReadonlyArray<string>, ) { const state = yield* Ref.get(stateRef); const terminalByProcessId = new Map<number, TerminalProcessOwner>(); for (const registration of state.terminalProcesses.values()) { for (const processId of registration.processIds) { terminalByProcessId.set(processId, registration.owner); } } if (hostPlatform === "win32") { const recoverWindowsProbeFailure = recoverProcessProbeFailure("windows-listeners"); const command = 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; const listeners = yield* processRunner .run({ command: "powershell.exe", args: ["-NoProfile", "-NonInteractive", "-Command", command], timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), maxOutputBytes: 1024 * 1024,