179 lines · 5.4 KB · TypeScript
RawDownload
import { Buffer } from "node:buffer"; import { Effect, Layer, Option, Schema, Stream } from "effect";import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import type { WebPortInspectorShape } from "../Services/WebPortInspector.ts";import { WebPortInspector, WebPortInspectionError } from "../Services/WebPortInspector.ts"; const DEFAULT_WEB_PORT_PROBE_TIMEOUT_MS = 2_000;const WEB_PORT_PROBE_MAX_BODY_BYTES = 8_192; interface WebProbeResult {  readonly status: number;  readonly contentType: string;  readonly body: string;  readonly location: string;} function normalizeHeaderValue(value: string | string[] | undefined): string {  if (typeof value === "string") return value;  if (Array.isArray(value)) return value[0] ?? "";  return "";} function isLikelyWebProbe(result: WebProbeResult | null): boolean {  if (!result) return false;  if (result.status === 404) return false;  if (result.status >= 300 && result.status < 400 && result.location.length > 0) {    return true;  }  const contentType = result.contentType.toLowerCase();  if (contentType.includes("text/html") || contentType.includes("application/xhtml+xml")) {    return true;  }  const body = result.body.toLowerCase();  return body.includes("<!doctype") || body.includes("<html") || body.includes("<head");} const collectBodyPreview = <E>(  stream: Stream.Stream<Uint8Array, E>,): Effect.Effect<string, never> =>  stream.pipe(    Stream.decodeText(),    Stream.runFold(      () => ({        text: "",        bytes: 0,      }),      (state, chunk) => {        if (state.bytes >= WEB_PORT_PROBE_MAX_BODY_BYTES) {          return state;        }         const chunkBytes = Buffer.byteLength(chunk);        const remainingBytes = WEB_PORT_PROBE_MAX_BODY_BYTES - state.bytes;        if (chunkBytes <= remainingBytes) {          return {            text: `${state.text}${chunk}`,            bytes: state.bytes + chunkBytes,          };        }         return {          text: `${state.text}${Buffer.from(chunk).subarray(0, remainingBytes).toString("utf8")}`,          bytes: state.bytes + remainingBytes,        };      },    ),    Effect.map((preview) => preview.text),    Effect.orElseSucceed(() => ""),  ); const makeWebPortInspector = Effect.gen(function* () {  const httpClient = yield* HttpClient.HttpClient;   const probeWebPortOnHost = Effect.fn("process.probeWebPortOnHost")(function* (    port: number,    host: string,  ): Effect.fn.Return<WebProbeResult | null, WebPortInspectionError> {    const request = HttpClientRequest.get(`http://${host}:${port}/`).pipe(      HttpClientRequest.setHeaders({        accept: "text/html,application/xhtml+xml;q=1,*/*;q=0.1",      }),    );     return yield* httpClient.execute(request).pipe(      Effect.flatMap((response) =>        Effect.gen(function* () {          const status = response.status;          const contentType = normalizeHeaderValue(response.headers["content-type"]);          const location = normalizeHeaderValue(response.headers.location);           if (            (status >= 300 && status < 400 && location.length > 0) ||            contentType.toLowerCase().includes("text/html") ||            contentType.toLowerCase().includes("application/xhtml+xml")          ) {            return {              status,              contentType,              location,              body: "",            } satisfies WebProbeResult;          }           const body = yield* collectBodyPreview(response.stream);          return {            status,            contentType,            location,            body,          } satisfies WebProbeResult;        }),      ),      Effect.timeoutOption(DEFAULT_WEB_PORT_PROBE_TIMEOUT_MS),      Effect.flatMap(        Option.match({          onNone: () =>            Effect.fail(              new WebPortInspectionError({                port,                host,                detail: "HTTP probe timed out.",              }),            ),          onSome: Effect.succeed,        }),      ),      Effect.mapError((error) =>        Schema.is(WebPortInspectionError)(error)          ? error          : new WebPortInspectionError({              port,              host,              detail: "Failed to execute HTTP probe request.",              cause: error,            }),      ),    );  });   const inspect: WebPortInspectorShape["inspect"] = Effect.fn("process.inspectWebPort")(function* (    port: number,  ) {    if (!Number.isInteger(port) || port <= 0 || port > 65_535) {      return yield* new WebPortInspectionError({        port,        host: "127.0.0.1",        detail: "Port must be an integer between 1 and 65535.",      });    }     const ipv4Result = yield* probeWebPortOnHost(port, "127.0.0.1").pipe(Effect.exit);    if (ipv4Result._tag === "Success" && isLikelyWebProbe(ipv4Result.value)) {      return true;    }     const ipv6Result = yield* probeWebPortOnHost(port, "::1").pipe(Effect.exit);    if (ipv6Result._tag === "Success" && isLikelyWebProbe(ipv6Result.value)) {      return true;    }     if (ipv4Result._tag === "Success" || ipv6Result._tag === "Success") {      return false;    }     if (ipv6Result._tag === "Failure") {      return yield* Effect.failCause(ipv6Result.cause);    }     return yield* Effect.failCause(ipv4Result.cause);  });   return {    inspect,  } satisfies WebPortInspectorShape;}); export const WebPortInspectorLive = Layer.effect(WebPortInspector, makeWebPortInspector);