process.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. import { Context, Duration, Effect, Fiber, Layer, Schema, Stream } from "effect"
  2. import type { PlatformError } from "effect/PlatformError"
  3. import { ChildProcess } from "effect/unstable/process"
  4. import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
  5. import { CrossSpawnSpawner } from "./cross-spawn-spawner"
  6. import { makeGlobalNode } from "./effect/app-node"
  7. export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()("AppProcessError", {
  8. command: Schema.String,
  9. exitCode: Schema.optional(Schema.Number),
  10. stderr: Schema.optional(Schema.String),
  11. cause: Schema.optional(Schema.Defect()),
  12. }) {
  13. override get message() {
  14. const detail =
  15. this.stderr?.trim() || (this.cause instanceof Error ? this.cause.message : this.cause && String(this.cause))
  16. const status = this.exitCode === undefined ? "" : ` (exit ${this.exitCode})`
  17. return `Command failed${status}: ${this.command}${detail ? `: ${detail}` : ""}`
  18. }
  19. }
  20. export interface RunOptions {
  21. readonly combineOutput?: boolean
  22. readonly maxOutputBytes?: number
  23. readonly maxErrorBytes?: number
  24. readonly signal?: AbortSignal
  25. readonly timeout?: Duration.Input
  26. readonly stdin?: string | Uint8Array | Stream.Stream<Uint8Array, PlatformError>
  27. }
  28. export interface RunStreamOptions {
  29. readonly signal?: AbortSignal
  30. readonly includeStderr?: boolean
  31. readonly okExitCodes?: ReadonlyArray<number>
  32. readonly maxErrorBytes?: number
  33. }
  34. export interface RunResult {
  35. readonly command: string
  36. readonly exitCode: number
  37. readonly output?: Buffer
  38. readonly stdout: Buffer
  39. readonly stderr: Buffer
  40. readonly outputTruncated?: boolean
  41. readonly stdoutTruncated: boolean
  42. readonly stderrTruncated: boolean
  43. }
  44. export type Interface = ChildProcessSpawner["Service"] & {
  45. readonly run: (command: ChildProcess.Command, options?: RunOptions) => Effect.Effect<RunResult, AppProcessError>
  46. readonly runStream: (
  47. command: ChildProcess.Command,
  48. options?: RunStreamOptions,
  49. ) => Stream.Stream<string, AppProcessError>
  50. }
  51. export class Service extends Context.Service<Service, Interface>()("@kirincode/AppProcess") {}
  52. export const requireSuccess = (result: RunResult): Effect.Effect<RunResult, AppProcessError> =>
  53. result.exitCode === 0
  54. ? Effect.succeed(result)
  55. : Effect.fail(
  56. new AppProcessError({
  57. command: result.command,
  58. exitCode: result.exitCode,
  59. stderr: result.stderr.toString("utf8"),
  60. }),
  61. )
  62. export const requireExitIn =
  63. (codes: ReadonlyArray<number>) =>
  64. (result: RunResult): Effect.Effect<RunResult, AppProcessError> =>
  65. codes.includes(result.exitCode)
  66. ? Effect.succeed(result)
  67. : Effect.fail(
  68. new AppProcessError({
  69. command: result.command,
  70. exitCode: result.exitCode,
  71. stderr: result.stderr.toString("utf8"),
  72. }),
  73. )
  74. const describeCommand = (command: ChildProcess.Command): string => {
  75. if (command._tag === "StandardCommand") {
  76. return command.args.length ? `${command.command} ${command.args.join(" ")}` : command.command
  77. }
  78. return `${describeCommand(command.left)} | ${describeCommand(command.right)}`
  79. }
  80. const wrapError = (description: string, cause: unknown): AppProcessError =>
  81. cause instanceof AppProcessError ? cause : new AppProcessError({ command: description, cause })
  82. export const abortError = (signal: AbortSignal): Error => {
  83. const reason = signal.reason
  84. if (reason instanceof Error) return reason
  85. const err = new Error("Aborted")
  86. err.name = "AbortError"
  87. return err
  88. }
  89. export const waitForAbort = (signal: AbortSignal) =>
  90. Effect.callback<never, Error>((resume) => {
  91. if (signal.aborted) {
  92. resume(Effect.fail(abortError(signal)))
  93. return
  94. }
  95. const onabort = () => resume(Effect.fail(abortError(signal)))
  96. signal.addEventListener("abort", onabort, { once: true })
  97. return Effect.sync(() => signal.removeEventListener("abort", onabort))
  98. })
  99. const normalizeStdin = (
  100. input: string | Uint8Array | Stream.Stream<Uint8Array, PlatformError>,
  101. ): Stream.Stream<Uint8Array, PlatformError> =>
  102. typeof input === "string"
  103. ? Stream.make(new TextEncoder().encode(input))
  104. : input instanceof Uint8Array
  105. ? Stream.make(input)
  106. : input
  107. export const collectStream = (stream: Stream.Stream<Uint8Array, PlatformError>, maxOutputBytes: number | undefined) =>
  108. Stream.runFold(
  109. stream,
  110. () => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }),
  111. (acc, chunk) => {
  112. if (maxOutputBytes === undefined) {
  113. acc.chunks.push(chunk)
  114. acc.bytes += chunk.length
  115. return acc
  116. }
  117. const remaining = maxOutputBytes - acc.bytes
  118. if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining))
  119. acc.bytes += chunk.length
  120. acc.truncated = acc.truncated || acc.bytes > maxOutputBytes
  121. return acc
  122. },
  123. ).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated })))
  124. const layer = Layer.effect(
  125. Service,
  126. Effect.gen(function* () {
  127. const spawner = yield* ChildProcessSpawner
  128. const runCommand = (command: ChildProcess.Command, options?: RunOptions) => {
  129. const description = describeCommand(command)
  130. const collect = Effect.scoped(
  131. Effect.gen(function* () {
  132. const handle = yield* spawner.spawn(command)
  133. if (options?.combineOutput) {
  134. const [output, exitCode] = yield* Effect.all(
  135. [collectStream(handle.all, options.maxOutputBytes), handle.exitCode],
  136. { concurrency: "unbounded" },
  137. )
  138. return {
  139. command: description,
  140. exitCode,
  141. output: output.buffer,
  142. stdout: Buffer.alloc(0),
  143. stderr: Buffer.alloc(0),
  144. outputTruncated: output.truncated,
  145. stdoutTruncated: false,
  146. stderrTruncated: false,
  147. } satisfies RunResult
  148. }
  149. const [stdout, stderr, exitCode] = yield* Effect.all(
  150. [
  151. collectStream(handle.stdout, options?.maxOutputBytes),
  152. collectStream(handle.stderr, options?.maxErrorBytes),
  153. handle.exitCode,
  154. ],
  155. { concurrency: "unbounded" },
  156. )
  157. return {
  158. command: description,
  159. exitCode,
  160. stdout: stdout.buffer,
  161. stderr: stderr.buffer,
  162. stdoutTruncated: stdout.truncated,
  163. stderrTruncated: stderr.truncated,
  164. } satisfies RunResult
  165. }),
  166. )
  167. const timed = options?.timeout
  168. ? Effect.timeoutOrElse(collect, {
  169. duration: options.timeout,
  170. orElse: () => Effect.fail(new AppProcessError({ command: description, cause: new Error("Timed out") })),
  171. })
  172. : collect
  173. const aborted = options?.signal
  174. ? timed.pipe(
  175. Effect.raceFirst(
  176. waitForAbort(options.signal).pipe(Effect.mapError((cause) => wrapError(description, cause))),
  177. ),
  178. )
  179. : timed
  180. return aborted.pipe(Effect.catch((cause) => Effect.fail(wrapError(description, cause))))
  181. }
  182. const run = Effect.fn("AppProcess.run")(function* (command: ChildProcess.Command, options?: RunOptions) {
  183. if (options?.stdin === undefined) return yield* runCommand(command, options)
  184. if (command._tag !== "StandardCommand") {
  185. return yield* new AppProcessError({
  186. command: describeCommand(command),
  187. cause: new Error("stdin option only supports StandardCommand; received PipedCommand"),
  188. })
  189. }
  190. const next = ChildProcess.make(command.command, command.args, {
  191. ...command.options,
  192. stdin: normalizeStdin(options.stdin),
  193. })
  194. return yield* runCommand(next, options)
  195. })
  196. const runStream = (
  197. command: ChildProcess.Command,
  198. options?: RunStreamOptions,
  199. ): Stream.Stream<string, AppProcessError> => {
  200. const description = describeCommand(command)
  201. const okExitCodes = options?.okExitCodes
  202. const built: Stream.Stream<string, AppProcessError | PlatformError> = Stream.unwrap(
  203. Effect.gen(function* () {
  204. const handle = yield* spawner.spawn(command)
  205. const stderrFiber = yield* Effect.forkScoped(
  206. collectStream(handle.stderr, options?.maxErrorBytes).pipe(Effect.map((x) => x.buffer.toString("utf8"))),
  207. )
  208. const source = options?.includeStderr === true ? handle.all : handle.stdout
  209. const lines = source.pipe(
  210. Stream.decodeText,
  211. Stream.splitLines,
  212. Stream.filter((line) => line.length > 0),
  213. )
  214. const tail = Stream.unwrap(
  215. Effect.gen(function* () {
  216. const code = yield* handle.exitCode
  217. if (okExitCodes && okExitCodes.length > 0 && !okExitCodes.includes(code)) {
  218. const stderr = yield* Fiber.join(stderrFiber)
  219. return Stream.fail(new AppProcessError({ command: description, exitCode: code, stderr }))
  220. }
  221. return Stream.empty
  222. }),
  223. )
  224. return Stream.concat(lines, tail) as Stream.Stream<string, AppProcessError | PlatformError>
  225. }),
  226. )
  227. const mapped = built.pipe(
  228. Stream.catch((cause): Stream.Stream<string, AppProcessError> => Stream.fail(wrapError(description, cause))),
  229. )
  230. if (!options?.signal) return mapped
  231. const signal = options.signal
  232. return mapped.pipe(
  233. Stream.interruptWhen(waitForAbort(signal).pipe(Effect.mapError((cause) => wrapError(description, cause)))),
  234. )
  235. }
  236. return Service.of({ ...spawner, run, runStream })
  237. }),
  238. )
  239. export const node = makeGlobalNode({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] })
  240. export * as AppProcess from "./process"