Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d74c6e9
spike out process logging middleware
jbolda Mar 16, 2026
6c018bc
switch to scope eval instead of resource
jbolda Mar 26, 2026
9e9bec7
fmt
jbolda Mar 26, 2026
4341d67
reorder TS refs to fix build order
jbolda Mar 26, 2026
4f929ac
process tsconfig
jbolda Mar 26, 2026
5088fc9
context-api in process/tsconfig
jbolda Mar 26, 2026
24d0856
remove context change
jbolda Mar 26, 2026
c634ca8
bump package to 0.8.0
jbolda Mar 27, 2026
cafeae6
trim io test
jbolda Mar 27, 2026
05dcbd5
trim other io test
jbolda Mar 27, 2026
38880a3
write bytes, string once
jbolda Mar 27, 2026
0cc65de
byte the bullet
jbolda Mar 27, 2026
48886cd
stderr to stderr
jbolda Mar 27, 2026
2892e44
rename api and type names
jbolda Mar 27, 2026
65ec51d
save stderr separate from stdout
jbolda Mar 27, 2026
ddf3974
Fix stderr middleware bug and flaky io api tests (#205)
taras Mar 28, 2026
02da077
treekill on null exitCode
jbolda Mar 28, 2026
0fcb970
readme example fix
jbolda Mar 28, 2026
da2b499
jsdocs
jbolda Mar 29, 2026
341799e
Merge branch 'main' into process-logging-middleware
jbolda Mar 29, 2026
cf59625
type import tweaks
jbolda Mar 29, 2026
22aaedc
remove context api change
jbolda Mar 29, 2026
1645afb
evalScope around in win32
jbolda Mar 29, 2026
49ac4f7
move win32 shutdown stdio wait
jbolda Mar 29, 2026
0288c05
wait on stdio to close before taskkill
jbolda Mar 29, 2026
5b889fb
export operations
jbolda Apr 5, 2026
95a99f1
Merge branch 'main' into process-logging-middleware
jbolda Apr 6, 2026
47be6bc
Revert "export operations"
jbolda Apr 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 28 additions & 1 deletion process/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ await main(function* () {

// Stream stdout in real-time
yield* spawn(function* () {
for (let chunk of yield* each(yield* process.stdout)) {
for (let chunk of yield* each(process.stdout)) {
console.log(chunk);
yield* each.next();
}
Expand All @@ -59,6 +59,33 @@ await main(function* () {
});
```

### Handling Process Output With Middleware

By default, we log the output, but you can remove or add additional handling of output lines per `stdout` and `stderr`.

```typescript
import { each, main, spawn } from "effection";
import { exec } from "@effectionx/process";

await main(function* () {
let process = yield* exec("npm install");

yield* process.around({
*stdout(line) {
// it does this by default
process.stdout.write(line);
},
*stderr(line) {
// it does this by default
process.stderr.write(line);
},
Comment thread
jbolda marked this conversation as resolved.
});

// Wait for the process to complete
yield* process.expect();
});
```

### Sending Input to stdin

Write to a process's stdin:
Expand Down
1 change: 1 addition & 0 deletions process/mod.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./src/exec.ts";
export { type Daemon, daemon } from "./src/daemon.ts";
export * from "./src/api.ts";
4 changes: 3 additions & 1 deletion process/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@effectionx/process",
"description": "Spawn and manage child processes with structured concurrency",
"version": "0.7.4",
"version": "0.8.0",
"keywords": ["process"],
"type": "module",
"main": "./dist/mod.js",
Expand All @@ -28,7 +28,9 @@
},
"sideEffects": false,
"dependencies": {
"@effectionx/context-api": "workspace:*",
"@effectionx/node": "workspace:*",
"@effectionx/scope-eval": "workspace:*",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"cross-spawn": "^7",
"ctrlc-windows": "^2",
"shellwords-ts": "^3.0.1"
Expand Down
48 changes: 48 additions & 0 deletions process/src/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { createApi } from "@effectionx/context-api";
import type { StdioApi } from "./exec/types.ts";

/**
* Context API used to observe or customize process stdio handling.
*
* By default, `stdout` and `stderr` are written directly to the host process
* streams. Middleware can wrap this API via `Stdio.around(...)` to capture,
* transform, or redirect child process output.
*
* @example
* ```ts
* import { main } from "effection";
* import { Stdio, exec } from "@effectionx/process";
*
* await main(function* () {
* let outputStdout: Uint8Array[] = [];
* let outputStderr: Uint8Array[] = [];
*
* // affects child processes in this scope
* // and all child scopes unless overridden
* yield* Stdio.around({
* *stdout(line, next) {
* const [bytes] = line;
* outputStdout.push(bytes);
* return yield* next(line);
* },
* *stderr(line, next) {
* const [bytes] = line;
* outputStderr.push(bytes);
* return yield* next(line);
* },
* });
*
* yield* exec("node ./fixtures/hello-world.js", {
* cwd: import.meta.dirname,
* }).expect();
* });
* ```
*/
export const Stdio = createApi<StdioApi>("process:io", {
*stdout(line) {
process.stdout.write(line);
},
*stderr(line) {
process.stderr.write(line);
},
});
26 changes: 24 additions & 2 deletions process/src/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,22 @@ import type {
ExitStatus,
Process,
ProcessResult,
} from "./exec/api.ts";
} from "./exec/types.ts";
import { createPosixProcess } from "./exec/posix.ts";
import { createWin32Process, isWin32 } from "./exec/win32.ts";

export * from "./exec/api.ts";
export * from "./exec/types.ts";
export * from "./exec/error.ts";

export interface Exec extends Operation<Process> {
/**
* Wait for process completion and return exit status plus captured output.
*/
join(): Operation<ProcessResult>;

/**
* Like `join()`, but throws if the process exits unsuccessfully.
*/
expect(): Operation<ProcessResult>;
}

Expand All @@ -31,6 +38,21 @@ const createProcess: CreateOSProcess = (cmd, opts) => {
* that have a finite lifetime and on which you may wish to synchronize on the
* exit status. If you want to start a process like a server that spins up and runs
* forever, consider using `daemon()`
*
* @example
* ```ts
* import { main } from "effection";
* import { exec } from "@effectionx/process";
*
* await main(function* () {
* let process = yield* exec("node ./fixtures/hello-world.js", {
* cwd: import.meta.dirname,
* })
* let result = yield* process.expect();
*
* console.log(result.code); // 0
* });
* ```
*/
export function exec(command: string, options: ExecOptions = {}): Exec {
let [cmd, ...args] = options.shell ? [command] : shellwords.split(command);
Expand Down
2 changes: 1 addition & 1 deletion process/src/exec/error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ExecOptions, ExitStatus } from "./api.ts";
import type { ExecOptions, ExitStatus } from "./types.ts";

export class ExecError extends Error {
status: ExitStatus;
Expand Down
65 changes: 43 additions & 22 deletions process/src/exec/posix.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,39 @@
import { spawn as spawnProcess } from "node:child_process";
import process from "node:process";
import { once } from "@effectionx/node/events";
import { fromReadable } from "@effectionx/node/stream";
import {
type Result,
type Operation,
type Yielded,
Err,
Ok,
type Result,
all,
createSignal,
resource,
ensure,
spawn,
withResolvers,
} from "effection";
import type { CreateOSProcess, ExitStatus, Writable } from "./api.ts";
import { unbox, useEvalScope } from "@effectionx/scope-eval";
import { once } from "@effectionx/node/events";
import { fromReadable } from "@effectionx/node/stream";
import type {
CreateOSProcess,
ExecOptions,
ExitStatus,
Process,
Writable,
} from "./types.ts";
import { Stdio } from "../api.ts";
import { ExecError } from "./error.ts";

type ProcessResultValue = [number?, string?];

export const createPosixProcess: CreateOSProcess = (command, options) => {
return resource(function* (provide) {
let processResult = withResolvers<Result<ProcessResultValue>>();

export function* createPosixProcess(
command: string,
options: ExecOptions,
): Operation<Process> {
let processResult = withResolvers<Result<ProcessResultValue>>();
const evalScope = yield* useEvalScope();
const result = yield* evalScope.eval(function* () {
// Killing all child processes started by this command is surprisingly
// tricky. If a process spawns another processes and we kill the parent,
// then the child process is NOT automatically killed. Instead we're using
Expand Down Expand Up @@ -58,6 +71,7 @@ export const createPosixProcess: CreateOSProcess = (command, options) => {
yield* spawn(function* () {
let next = yield* io.stdout.next();
while (!next.done) {
yield* Stdio.operations.stdout(next.value);
stdout.send(next.value);
next = yield* io.stdout.next();
}
Expand All @@ -68,6 +82,7 @@ export const createPosixProcess: CreateOSProcess = (command, options) => {
yield* spawn(function* () {
let next = yield* io.stderr.next();
while (!next.done) {
yield* Stdio.operations.stderr(next.value);
stderr.send(next.value);
next = yield* io.stderr.next();
}
Expand Down Expand Up @@ -108,26 +123,32 @@ export const createPosixProcess: CreateOSProcess = (command, options) => {
return status;
}

try {
yield* provide({
pid: pid as number,
stdin,
stdout,
stderr,
join,
expect,
});
} finally {
yield* ensure(function* () {
try {
if (typeof childProcess.pid === "undefined") {
// biome-ignore lint/correctness/noUnsafeFinally: Intentional error for missing PID
throw new Error("no pid for childProcess");
}
process.kill(-childProcess.pid, "SIGTERM");
yield* all([io.stdoutDone.operation, io.stderrDone.operation]);
} catch (_e) {
// do nothing, process is probably already dead
}
}
});
Comment thread
jbolda marked this conversation as resolved.

return {
pid: pid as number,
*around(
...args: Parameters<typeof Stdio.around>
): ReturnType<typeof Stdio.around> {
const result = yield* evalScope.eval(() => Stdio.around(...args));
return unbox(result);
},
stdin,
stdout,
stderr,
join,
expect,
} satisfies Yielded<ReturnType<CreateOSProcess>>;
});
};
return unbox(result);
}
54 changes: 52 additions & 2 deletions process/src/exec/api.ts → process/src/exec/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { Operation } from "effection";
import type { OutputStream } from "../helpers.ts";
import type { Api } from "@effectionx/context-api";

// TODO: import from subscription package once #236 is merged
/**
* Writable handle used for process stdin.
*/
export interface Writable<T> {
send(message: T): void;
}
Expand All @@ -11,6 +14,7 @@ export interface Writable<T> {
* standard io handles, and methods for synchronizing on return.
*/
export interface Process extends StdIO {
/** Child process id as reported by the operating system. */
readonly pid: number;

/**
Expand All @@ -24,8 +28,42 @@ export interface Process extends StdIO {
* not complete successfully, it will raise an ExecError.
*/
expect(): Operation<ExitStatus>;

/**
* Middleware entrypoint for wrapping stdio behavior using `Stdio` middleware API.
*
* @example
* ```ts
* import { main } from "effection";
* import { exec } from "@effectionx/process";
*
* await main(function* () {
* let proc = yield* exec("node ./fixtures/hello-world.js", {
* cwd: import.meta.dirname,
* });
*
* let chunks: Uint8Array[] = [];
* yield* proc.around({
* *stdout(line, next) {
* // handle bytes as required
* const [bytes] = line;
* chunks.push(bytes);
* // optionally continue with next middleware
* return yield* next(line);
* },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
* });
*
* yield* proc.expect();
* console.log(chunks.toString());
* });
* ```
*/
around: Api<StdioApi>["around"];
}

/**
* Options for spawning a child process.
*/
export interface ExecOptions {
/**
* When not using passing the `shell` option all arguments must be passed
Expand Down Expand Up @@ -54,15 +92,24 @@ export interface ExecOptions {
}

export interface StdIO {
/** Stream of bytes written by the process to standard output. */
stdout: OutputStream;

/** Stream of bytes written by the process to standard error. */
stderr: OutputStream;

/** Writable interface for sending data to process standard input. */
stdin: Writable<string>;
}

export interface StdioApi {
stdout(bytes: Uint8Array): Operation<void>;
stderr(bytes: Uint8Array): Operation<void>;
}

export interface ExitStatus {
/**
* exit code
* //TODO: is this pertinent on Windows? Do we need an 'OK' flag
*/
code?: number;

Expand All @@ -74,7 +121,10 @@ export interface ExitStatus {
}

export interface ProcessResult extends ExitStatus {
/** Collected stdout text from process execution helpers. */
stdout: string;

/** Collected stderr text from process execution helpers. */
stderr: string;
}
export type CreateOSProcess = (
Expand Down
Loading
Loading