diff --git a/process/api.ts b/process/api.ts new file mode 100644 index 00000000..40571343 --- /dev/null +++ b/process/api.ts @@ -0,0 +1,40 @@ +import { type Api, createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; +import { resource } from "effection"; + +import type { Daemon } from "./src/daemon.ts"; +import type { ExecOptions, Process } from "./src/exec/types.ts"; +import { DaemonExitError } from "./src/exec/error.ts"; +import { createPosixProcess } from "./src/exec/posix.ts"; +import { createWin32Process, isWin32 } from "./src/exec/win32.ts"; + +export interface ProcessHandler { + exec(command: string, options: ExecOptions): Operation; + daemon(command: string, options: ExecOptions): Operation; +} + +export const ProcessApi: Api = createApi( + "@effectionx/process", + { + *exec(command: string, options: ExecOptions): Operation { + if (isWin32()) { + return yield* createWin32Process(command, options); + } + return yield* createPosixProcess(command, options); + }, + + *daemon(command: string, options: ExecOptions): Operation { + return yield* resource(function* (provide) { + let process = yield* ProcessApi.operations.exec(command, options); + + yield* provide({ + *[Symbol.iterator]() { + let status = yield* process.join(); + throw new DaemonExitError(status, command, options); + }, + ...process, + }); + }); + }, + }, +); diff --git a/process/mod.ts b/process/mod.ts index b120b112..0e2f112f 100644 --- a/process/mod.ts +++ b/process/mod.ts @@ -1,3 +1,4 @@ +export * from "./api.ts"; export * from "./src/exec.ts"; export { type Daemon, daemon } from "./src/daemon.ts"; export * from "./src/api.ts"; diff --git a/process/src/api.ts b/process/src/api.ts index b6a546bf..5d440869 100644 --- a/process/src/api.ts +++ b/process/src/api.ts @@ -38,7 +38,7 @@ import type { StdioApi } from "./exec/types.ts"; * }); * ``` */ -export const Stdio = createApi("process:io", { +export const Stdio = createApi("@effectionx/process:io", { *stdout(line) { process.stdout.write(line); }, diff --git a/process/src/daemon.ts b/process/src/daemon.ts index 820bc927..18cda244 100644 --- a/process/src/daemon.ts +++ b/process/src/daemon.ts @@ -1,12 +1,7 @@ -import { type Operation, resource } from "effection"; +import type { Operation } from "effection"; -import { - DaemonExitError, - exec, - type ExecOptions, - type ExitStatus, - type Process, -} from "./exec.ts"; +import { ProcessApi } from "../api.ts"; +import type { ExecOptions, Process } from "./exec.ts"; export interface Daemon extends Operation, Process {} @@ -19,16 +14,5 @@ export function daemon( command: string, options: ExecOptions = {}, ): Operation { - return resource(function* (provide) { - // TODO: should we be able to terminate the process from here? - let process = yield* exec(command, options); - - yield* provide({ - *[Symbol.iterator]() { - let status: ExitStatus = yield* process.join(); - throw new DaemonExitError(status, command, options); - }, - ...process, - }); - }); + return ProcessApi.operations.daemon(command, options); } diff --git a/process/src/exec.ts b/process/src/exec.ts index 9474e088..2f7559e2 100644 --- a/process/src/exec.ts +++ b/process/src/exec.ts @@ -2,14 +2,13 @@ import shellwords from "shellwords-ts"; import { type Operation, spawn } from "effection"; import type { - CreateOSProcess, ExecOptions, ExitStatus, Process, ProcessResult, } from "./exec/types.ts"; -import { createPosixProcess } from "./exec/posix.ts"; -import { createWin32Process, isWin32 } from "./exec/win32.ts"; + +import { ProcessApi } from "../api.ts"; export * from "./exec/types.ts"; export * from "./exec/error.ts"; @@ -26,13 +25,6 @@ export interface Exec extends Operation { expect(): Operation; } -const createProcess: CreateOSProcess = (cmd, opts) => { - if (isWin32()) { - return createWin32Process(cmd, opts); - } - return createPosixProcess(cmd, opts); -}; - /** * Execute `command` with `options`. You should use this operation for processes * that have a finite lifetime and on which you may wish to synchronize on the @@ -60,10 +52,10 @@ export function exec(command: string, options: ExecOptions = {}): Exec { return { *[Symbol.iterator]() { - return yield* createProcess(cmd, opts); + return yield* ProcessApi.operations.exec(cmd, opts); }, *join() { - const process = yield* createProcess(cmd, opts); + const process = yield* ProcessApi.operations.exec(cmd, opts); let stdout = ""; let stderr = ""; @@ -91,7 +83,7 @@ export function exec(command: string, options: ExecOptions = {}): Exec { return { ...status, stdout, stderr }; }, *expect() { - const process = yield* createProcess(cmd, opts); + const process = yield* ProcessApi.operations.exec(cmd, opts); let stdout = ""; let stderr = ""; diff --git a/process/test/api.test.ts b/process/test/api.test.ts new file mode 100644 index 00000000..3514ceea --- /dev/null +++ b/process/test/api.test.ts @@ -0,0 +1,85 @@ +import { describe, it } from "@effectionx/vitest"; +import { scoped } from "effection"; +import { expect } from "expect"; + +import { type Process, ProcessApi, exec } from "../mod.ts"; + +describe("ProcessApi middleware", () => { + it("can intercept process creation with logging", function* () { + let commands: string[] = []; + + yield* ProcessApi.around({ + *exec(args, next) { + let [cmd] = args; + commands.push(cmd); + return yield* next(...args); + }, + }); + + yield* exec("node", { + arguments: ["-e", "console.log('hello')"], + }).join(); + + yield* exec("node", { + arguments: ["-e", "console.log('world')"], + }).join(); + + expect(commands).toEqual(["node", "node"]); + }); + + it("middleware is scoped and does not leak", function* () { + let outerCalls: string[] = []; + + yield* ProcessApi.around({ + *exec(args, next) { + outerCalls.push("outer"); + return yield* next(...args); + }, + }); + + yield* exec("node", { + arguments: ["-e", "console.log('before')"], + }).join(); + + expect(outerCalls).toEqual(["outer"]); + + yield* scoped(function* () { + let innerCalls: string[] = []; + + yield* ProcessApi.around({ + *exec(args, next) { + innerCalls.push("inner"); + return yield* next(...args); + }, + }); + + yield* exec("node", { + arguments: ["-e", "console.log('inner')"], + }).join(); + + // inner scope hits both outer and inner middleware + expect(outerCalls).toEqual(["outer", "outer"]); + expect(innerCalls).toEqual(["inner"]); + }); + + // after child scope exits, inner middleware is gone + outerCalls.length = 0; + yield* exec("node", { + arguments: ["-e", "console.log('after')"], + }).join(); + + expect(outerCalls).toEqual(["outer"]); + }); + + it("can mock process creation", function* () { + yield* ProcessApi.around({ + *exec(_args, _next) { + // Return a fake process without spawning anything + return { pid: 42 } as Process; + }, + }); + + let process = yield* exec("anything"); + expect(process.pid).toBe(42); + }); +});