414 lines · 12.4 KB · TypeScript
RawDownload
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 PlatformError from "effect/PlatformError";import * as Schema from "effect/Schema";import * as Scope from "effect/Scope";import * as Stream from "effect/Stream";import * as ChildProcess from "effect/unstable/process/ChildProcess";import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";import { HostProcessPlatform } from "@t3tools/shared/hostProcess";import { resolveSpawnCommand } from "@t3tools/shared/shell";import {  collectUint8StreamText,  decodeUtf8,  type CollectedUint8StreamText,} from "./stream/collectUint8StreamText.ts"; export interface ProcessRunInput {  readonly command: string;  readonly args: ReadonlyArray<string>;  readonly cwd?: string | undefined;  readonly spawnCwd?: string | undefined;  readonly timeout?: Duration.Input | undefined;  readonly env?: NodeJS.ProcessEnv | undefined;  readonly stdin?: string | undefined;  readonly maxOutputBytes?: number | undefined;  readonly outputMode?: "error" | "truncate" | undefined;  readonly truncatedMarker?: string | undefined;  /**   * On timeout, return a synthetic timedOut result.   * Partial stdout/stderr are not preserved.   */  readonly timeoutBehavior?: "error" | "timedOutResult" | undefined;} export interface ProcessRunOutput {  readonly stdout: string;  readonly stderr: string;  readonly code: ChildProcessSpawner.ExitCode | null;  readonly timedOut: boolean;  readonly stdoutTruncated: boolean;  readonly stderrTruncated: boolean;  readonly stdoutInvalidUtf8: boolean;  readonly stderrInvalidUtf8: boolean;} const ProcessInvocationFields = {  command: Schema.String,  argumentCount: Schema.Number,  cwd: Schema.optional(Schema.String),  spawnCwd: Schema.optional(Schema.String),}; const formatProcessInvocation = (input: {  readonly command: string;  readonly cwd?: string | undefined;  readonly spawnCwd?: string | undefined;}): string => {  const executionCwd = input.spawnCwd ?? input.cwd;  return executionCwd === undefined    ? `'${input.command}'`    : `'${input.command}' in '${executionCwd}'`;}; export class ProcessSpawnError extends Schema.TaggedError<ProcessSpawnError>()(  "ProcessSpawnError",  {    ...ProcessInvocationFields,    resolvedCommand: Schema.optional(Schema.String),    resolvedArgumentCount: Schema.optional(Schema.Number),    shell: Schema.optional(Schema.Boolean),    cause: Schema.Defect(),  },) {  override get message(): string {    return `Failed to spawn process ${formatProcessInvocation(this)}`;  }} export class ProcessStdinError extends Schema.TaggedError<ProcessStdinError>()(  "ProcessStdinError",  {    ...ProcessInvocationFields,    stdinBytes: Schema.Number,    cause: Schema.Defect(),  },) {  override get message(): string {    return `Failed to write stdin for process ${formatProcessInvocation(this)}`;  }} export class ProcessOutputLimitError extends Schema.TaggedError<ProcessOutputLimitError>()(  "ProcessOutputLimitError",  {    ...ProcessInvocationFields,    stream: Schema.Literals(["stdout", "stderr"]),    maxBytes: Schema.Number,    observedBytes: Schema.Number,  },) {  override get message(): string {    return `Process ${formatProcessInvocation(this)} ${this.stream} produced ${this.observedBytes} bytes, exceeding the ${this.maxBytes} byte limit`;  }} export class ProcessReadError extends Schema.TaggedError<ProcessReadError>()("ProcessReadError", {  ...ProcessInvocationFields,  stream: Schema.Literals(["stdout", "stderr", "exitCode"]),  cause: Schema.Defect(),}) {  override get message(): string {    return `Failed to read ${this.stream} for process ${formatProcessInvocation(this)}`;  }} export class ProcessTimeoutError extends Schema.TaggedError<ProcessTimeoutError>()(  "ProcessTimeoutError",  {    ...ProcessInvocationFields,    timeoutMs: Schema.Number,  },) {  override get message(): string {    return `Process ${formatProcessInvocation(this)} timed out after ${this.timeoutMs}ms`;  }} export const ProcessRunError = Schema.Union([  ProcessSpawnError,  ProcessStdinError,  ProcessOutputLimitError,  ProcessReadError,  ProcessTimeoutError,]);export type ProcessRunError = typeof ProcessRunError.Type; export class ProcessRunner extends Context.Service<  ProcessRunner,  {    readonly run: (input: ProcessRunInput) => Effect.Effect<ProcessRunOutput, ProcessRunError>;  }>()("t3/processRunner") {} const DEFAULT_TIMEOUT = "60 seconds";const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; const WINDOWS_COMMAND_NOT_FOUND_PATTERNS = [  /is not recognized as an internal or external command/i,  /n.o . reconhecido como um comando interno/i,  /non . riconosciuto come comando interno o esterno/i,  /n.est pas reconnu en tant que commande interne/i,  /no se reconoce como un comando interno o externo/i,  /wird nicht als interner oder externer befehl/i,] as const; function hasWindowsCommandNotFoundMessage(output: string): boolean {  return WINDOWS_COMMAND_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(output));} export const isWindowsCommandNotFound = Effect.fn("processRunner.isWindowsCommandNotFound")(  function* (code: number | null, stderr: string) {    const platform = yield* HostProcessPlatform;    if (platform !== "win32") return false;    if (code === 9009) return true;    return hasWindowsCommandNotFoundMessage(stderr);  },); const collectText = Effect.fn("processRunner.collectText")(function* (input: {  readonly command: string;  readonly args: ReadonlyArray<string>;  readonly cwd?: string | undefined;  readonly spawnCwd?: string | undefined;  readonly streamName: "stdout" | "stderr";  readonly stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>;  readonly maxOutputBytes: number;  readonly outputMode: "error" | "truncate";  readonly truncatedMarker: string;}) {  const stream = input.stream.pipe(    Stream.mapError(      (cause) =>        new ProcessReadError({          command: input.command,          argumentCount: input.args.length,          cwd: input.cwd,          spawnCwd: input.spawnCwd,          stream: input.streamName,          cause,        }),    ),  );   if (input.outputMode === "truncate") {    return yield* collectUint8StreamText({      stream,      maxBytes: input.maxOutputBytes,      truncatedMarker: input.truncatedMarker,    });  }   return yield* stream.pipe(    Stream.runFoldEffect<      {        readonly chunks: Uint8Array<ArrayBufferLike>[];        readonly bytes: number;      },      Uint8Array<ArrayBufferLike>,      ProcessOutputLimitError | ProcessReadError,      never    >(      () => ({ chunks: [], bytes: 0 }),      (state, chunk) => {        const remainingBytes = input.maxOutputBytes - state.bytes;        if (chunk.byteLength > remainingBytes) {          return Effect.fail(            new ProcessOutputLimitError({              command: input.command,              argumentCount: input.args.length,              cwd: input.cwd,              spawnCwd: input.spawnCwd,              stream: input.streamName,              maxBytes: input.maxOutputBytes,              observedBytes: state.bytes + chunk.byteLength,            }),          );        }         state.chunks.push(chunk);        return Effect.succeed({          chunks: state.chunks,          bytes: state.bytes + chunk.byteLength,        });      },    ),    Effect.map((state): CollectedUint8StreamText => ({      ...decodeUtf8(Buffer.concat(state.chunks, state.bytes)),      bytes: state.bytes,      truncated: false,    })),  );}); function finalizeRunProcess<R>(  effect: Effect.Effect<ProcessRunOutput, ProcessRunError, R | Scope.Scope>,  input: ProcessRunInput,): Effect.Effect<ProcessRunOutput, ProcessRunError, Exclude<R, Scope.Scope>> {  const timeout = Duration.fromInputUnsafe(input.timeout ?? DEFAULT_TIMEOUT);  const timeoutBehavior = input.timeoutBehavior ?? "error";   return effect.pipe(    Effect.scoped,    Effect.timeoutOption(timeout),    Effect.flatMap((result) => {      if (Option.isSome(result)) {        return Effect.succeed(result.value);      }      if (timeoutBehavior === "timedOutResult") {        return Effect.succeed({          stdout: "",          stderr: "",          code: null,          timedOut: true,          stdoutTruncated: false,          stderrTruncated: false,          stdoutInvalidUtf8: false,          stderrInvalidUtf8: false,        } satisfies ProcessRunOutput);      }      return Effect.fail(        new ProcessTimeoutError({          command: input.command,          argumentCount: input.args.length,          cwd: input.cwd,          spawnCwd: input.spawnCwd,          timeoutMs: Duration.toMillis(timeout),        }),      );    }),  );} const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* (  spawner: ChildProcessSpawner.ChildProcessSpawner["Service"],  input: ProcessRunInput,): Effect.fn.Return<ProcessRunOutput, ProcessRunError, Scope.Scope> {  const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;  const outputMode = input.outputMode ?? "error";  const truncatedMarker = input.truncatedMarker ?? "";  const extendEnv = input.env !== undefined;  const spawnCommand = yield* resolveSpawnCommand(    input.command,    input.args,    input.env === undefined ? {} : { env: input.env, extendEnv },  );   const child = yield* spawner    .spawn(      ChildProcess.make(spawnCommand.command, spawnCommand.args, {        ...((input.spawnCwd ?? input.cwd) ? { cwd: input.spawnCwd ?? input.cwd } : {}),        ...(input.env !== undefined          ? {              env: input.env,              extendEnv,            }          : {}),        shell: spawnCommand.shell,      }),    )    .pipe(      Effect.mapError(        (cause) =>          new ProcessSpawnError({            command: input.command,            argumentCount: input.args.length,            cwd: input.cwd,            spawnCwd: input.spawnCwd,            resolvedCommand: spawnCommand.command,            resolvedArgumentCount: spawnCommand.args.length,            shell: spawnCommand.shell,            cause,          }),      ),    );   const stdin = input.stdin;  const writeStdin =    stdin === undefined      ? Effect.void      : Stream.run(Stream.encodeText(Stream.make(stdin)), child.stdin).pipe(          Effect.mapError(            (cause) =>              new ProcessStdinError({                command: input.command,                argumentCount: input.args.length,                cwd: input.cwd,                spawnCwd: input.spawnCwd,                stdinBytes: Buffer.byteLength(stdin),                cause,              }),          ),        );   const [stdout, stderr] = yield* Effect.all(    [      collectText({        command: input.command,        args: input.args,        cwd: input.cwd,        spawnCwd: input.spawnCwd,        streamName: "stdout",        stream: child.stdout,        maxOutputBytes,        outputMode,        truncatedMarker,      }),      collectText({        command: input.command,        args: input.args,        cwd: input.cwd,        spawnCwd: input.spawnCwd,        streamName: "stderr",        stream: child.stderr,        maxOutputBytes,        outputMode,        truncatedMarker,      }),      writeStdin,    ],    { concurrency: "unbounded" },  );   const exitCode = yield* child.exitCode.pipe(    Effect.mapError(      (cause) =>        new ProcessReadError({          command: input.command,          argumentCount: input.args.length,          cwd: input.cwd,          spawnCwd: input.spawnCwd,          stream: "exitCode",          cause,        }),    ),  );   return {    stdout: stdout.text,    stderr: stderr.text,    code: exitCode,    timedOut: false,    stdoutTruncated: stdout.truncated,    stderrTruncated: stderr.truncated,    stdoutInvalidUtf8: stdout.invalidUtf8,    stderrInvalidUtf8: stderr.invalidUtf8,  } satisfies ProcessRunOutput;}); /** @public Service construction is part of the canonical Effect module API. */export const make = Effect.fn("ProcessRunner.make")(function* () {  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;   const run: ProcessRunner["Service"]["run"] = (input) =>    finalizeRunProcess(runProcessCore(spawner, input), input);   return ProcessRunner.of({    run,  });}); export const layer = Layer.effect(ProcessRunner, make());