665 lines · 24.2 KB · TypeScript
RawDownload
/** * 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.