From 2c89ab3eb8686c4335bd50552eb3f4878cd115a0 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 26 Mar 2026 23:27:55 -0400 Subject: [PATCH 01/23] Add ProcessApi middleware support with exec and daemon operations Introduce @effectionx/context-api integration for @effectionx/process, allowing middleware interception of process creation. Rename from createProcess to exec/daemon per review feedback, use ProcessHandler naming convention, bump version to 0.8.0. --- process/api.ts | 37 ++++++++++++++++ process/mod.ts | 1 + process/package.json | 2 +- process/src/daemon.ts | 24 ++--------- process/src/exec.ts | 18 +++----- process/test/api.test.ts | 92 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 140 insertions(+), 34 deletions(-) create mode 100644 process/api.ts create mode 100644 process/test/api.test.ts diff --git a/process/api.ts b/process/api.ts new file mode 100644 index 00000000..85e1b2c0 --- /dev/null +++ b/process/api.ts @@ -0,0 +1,37 @@ +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("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 { + let process = yield* ProcessApi.operations.exec(command, options); + + return yield* resource(function* (provide) { + 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/package.json b/process/package.json index 29ac74e6..c55b49d0 100644 --- a/process/package.json +++ b/process/package.json @@ -2,7 +2,7 @@ "name": "@effectionx/process", "description": "Spawn and manage child processes with structured concurrency", "version": "0.8.0", - "keywords": ["process"], + "keywords": ["effection", "effectionx", "process", "spawn", "exec", "child-process"], "type": "module", "main": "./dist/mod.js", "types": "./dist/mod.d.ts", 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..ce7d18e2 --- /dev/null +++ b/process/test/api.test.ts @@ -0,0 +1,92 @@ +import { describe, it } from "@effectionx/bdd"; +import { type Operation, scoped, spawn } from "effection"; +import { expect } from "expect"; + +import { 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): Operation { + // Return a fake process without spawning anything + return { + pid: 42, + stdout: { *[Symbol.iterator]() { return { done: true, value: void 0 }; } }, + stderr: { *[Symbol.iterator]() { return { done: true, value: void 0 }; } }, + stdin: { send() {} }, + *join() { return { code: 0 }; }, + *expect() { return { code: 0 }; }, + }; + }, + }); + + let process = yield* exec("anything"); + expect(process.pid).toBe(42); + }); +}); From 8378a3d413c95bd64cfa24e1d64313513dd817b9 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 26 Mar 2026 23:45:26 -0400 Subject: [PATCH 02/23] Fix biome formatting in process middleware test --- process/test/api.test.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/process/test/api.test.ts b/process/test/api.test.ts index ce7d18e2..6e478dcf 100644 --- a/process/test/api.test.ts +++ b/process/test/api.test.ts @@ -77,11 +77,23 @@ describe("ProcessApi middleware", () => { // Return a fake process without spawning anything return { pid: 42, - stdout: { *[Symbol.iterator]() { return { done: true, value: void 0 }; } }, - stderr: { *[Symbol.iterator]() { return { done: true, value: void 0 }; } }, + stdout: { + *[Symbol.iterator]() { + return { done: true, value: void 0 }; + }, + }, + stderr: { + *[Symbol.iterator]() { + return { done: true, value: void 0 }; + }, + }, stdin: { send() {} }, - *join() { return { code: 0 }; }, - *expect() { return { code: 0 }; }, + *join() { + return { code: 0 }; + }, + *expect() { + return { code: 0 }; + }, }; }, }); From bc10ba38a8362d698df530e95cca18a16dedb11f Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 26 Mar 2026 23:53:32 -0400 Subject: [PATCH 03/23] Fix lint: remove noExplicitAny and unused imports in test --- process/test/api.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/process/test/api.test.ts b/process/test/api.test.ts index 6e478dcf..d3ce1655 100644 --- a/process/test/api.test.ts +++ b/process/test/api.test.ts @@ -1,5 +1,5 @@ import { describe, it } from "@effectionx/bdd"; -import { type Operation, scoped, spawn } from "effection"; +import { scoped } from "effection"; import { expect } from "expect"; import { ProcessApi, exec } from "../mod.ts"; @@ -73,7 +73,7 @@ describe("ProcessApi middleware", () => { it("can mock process creation", function* () { yield* ProcessApi.around({ - *exec(_args, _next): Operation { + *exec(_args, _next) { // Return a fake process without spawning anything return { pid: 42, From 4280e4ea015f149f3573d933a348f7afa7c63584 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 26 Mar 2026 23:58:05 -0400 Subject: [PATCH 04/23] Update tsconfig refs and fix TypeScript error in mock test --- process/test/api.test.ts | 23 ++--------------------- process/tsconfig.json | 13 +++++++++++-- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/process/test/api.test.ts b/process/test/api.test.ts index d3ce1655..2dcf89f8 100644 --- a/process/test/api.test.ts +++ b/process/test/api.test.ts @@ -2,7 +2,7 @@ import { describe, it } from "@effectionx/bdd"; import { scoped } from "effection"; import { expect } from "expect"; -import { ProcessApi, exec } from "../mod.ts"; +import { type Process, ProcessApi, exec } from "../mod.ts"; describe("ProcessApi middleware", () => { it("can intercept process creation with logging", function* () { @@ -75,26 +75,7 @@ describe("ProcessApi middleware", () => { yield* ProcessApi.around({ *exec(_args, _next) { // Return a fake process without spawning anything - return { - pid: 42, - stdout: { - *[Symbol.iterator]() { - return { done: true, value: void 0 }; - }, - }, - stderr: { - *[Symbol.iterator]() { - return { done: true, value: void 0 }; - }, - }, - stdin: { send() {} }, - *join() { - return { code: 0 }; - }, - *expect() { - return { code: 0 }; - }, - }; + return { pid: 42 } as Process; }, }); diff --git a/process/tsconfig.json b/process/tsconfig.json index bdfb17ce..18b444ce 100644 --- a/process/tsconfig.json +++ b/process/tsconfig.json @@ -4,9 +4,18 @@ "outDir": "dist", "rootDir": "." }, - "include": ["**/*.ts"], - "exclude": ["**/*.test.ts", "test/**", "dist"], + "include": [ + "**/*.ts" + ], + "exclude": [ + "**/*.test.ts", + "test/**", + "dist" + ], "references": [ + { + "path": "../context-api" + }, { "path": "../context-api" }, From cd90c3326b65c7c7d23e95c877f2a37fc6616cd3 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 27 Mar 2026 00:02:58 -0400 Subject: [PATCH 05/23] Fix biome formatting in tsconfig.json --- process/tsconfig.json | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/process/tsconfig.json b/process/tsconfig.json index 18b444ce..8dbd99bf 100644 --- a/process/tsconfig.json +++ b/process/tsconfig.json @@ -4,14 +4,8 @@ "outDir": "dist", "rootDir": "." }, - "include": [ - "**/*.ts" - ], - "exclude": [ - "**/*.test.ts", - "test/**", - "dist" - ], + "include": ["**/*.ts"], + "exclude": ["**/*.test.ts", "test/**", "dist"], "references": [ { "path": "../context-api" From c0dec2ea6f8a79dc2ff2e4d01319ec9b10785503 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 27 Mar 2026 00:15:15 -0400 Subject: [PATCH 06/23] Move exec call inside resource in daemon handler --- process/api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/process/api.ts b/process/api.ts index 85e1b2c0..10f9179b 100644 --- a/process/api.ts +++ b/process/api.ts @@ -22,9 +22,9 @@ export const ProcessApi: Api = createApi("process", { }, *daemon(command: string, options: ExecOptions): Operation { - let process = yield* ProcessApi.operations.exec(command, options); - return yield* resource(function* (provide) { + let process = yield* ProcessApi.operations.exec(command, options); + yield* provide({ *[Symbol.iterator]() { let status = yield* process.join(); From 3d958b00dfd48ae864e0963bb0f541e268aeeb66 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 9 Apr 2026 20:02:28 -0400 Subject: [PATCH 07/23] Migrate process test from @effectionx/bdd to @effectionx/vitest --- process/package.json | 9 ++++++++- process/test/api.test.ts | 2 +- process/tsconfig.json | 3 --- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/process/package.json b/process/package.json index c55b49d0..61c6a876 100644 --- a/process/package.json +++ b/process/package.json @@ -2,7 +2,14 @@ "name": "@effectionx/process", "description": "Spawn and manage child processes with structured concurrency", "version": "0.8.0", - "keywords": ["effection", "effectionx", "process", "spawn", "exec", "child-process"], + "keywords": [ + "effection", + "effectionx", + "process", + "spawn", + "exec", + "child-process" + ], "type": "module", "main": "./dist/mod.js", "types": "./dist/mod.d.ts", diff --git a/process/test/api.test.ts b/process/test/api.test.ts index 2dcf89f8..3514ceea 100644 --- a/process/test/api.test.ts +++ b/process/test/api.test.ts @@ -1,4 +1,4 @@ -import { describe, it } from "@effectionx/bdd"; +import { describe, it } from "@effectionx/vitest"; import { scoped } from "effection"; import { expect } from "expect"; diff --git a/process/tsconfig.json b/process/tsconfig.json index 8dbd99bf..bdfb17ce 100644 --- a/process/tsconfig.json +++ b/process/tsconfig.json @@ -7,9 +7,6 @@ "include": ["**/*.ts"], "exclude": ["**/*.test.ts", "test/**", "dist"], "references": [ - { - "path": "../context-api" - }, { "path": "../context-api" }, From 5d95acb88d8f3821193bfe75afea2d9c4840c250 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 9 Apr 2026 20:06:17 -0400 Subject: [PATCH 08/23] Revert keywords to match main --- process/package.json | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/process/package.json b/process/package.json index 61c6a876..29ac74e6 100644 --- a/process/package.json +++ b/process/package.json @@ -2,14 +2,7 @@ "name": "@effectionx/process", "description": "Spawn and manage child processes with structured concurrency", "version": "0.8.0", - "keywords": [ - "effection", - "effectionx", - "process", - "spawn", - "exec", - "child-process" - ], + "keywords": ["process"], "type": "module", "main": "./dist/mod.js", "types": "./dist/mod.d.ts", From a0879785eb2b591923434aafad3d7e33ca3da172 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 15 Apr 2026 20:31:43 -0400 Subject: [PATCH 09/23] Namespace api scope with @effectionx/process Align with the fs PR convention: use "@effectionx/process" and "@effectionx/process:io" as the context names instead of the bare "process" / "process:io" strings. Prevents collisions with any other package that might use the same unqualified scope. --- process/api.ts | 39 +++++++++++++++++++++------------------ process/src/api.ts | 2 +- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/process/api.ts b/process/api.ts index 10f9179b..40571343 100644 --- a/process/api.ts +++ b/process/api.ts @@ -13,25 +13,28 @@ export interface ProcessHandler { daemon(command: string, options: ExecOptions): Operation; } -export const ProcessApi: Api = createApi("process", { - *exec(command: string, options: ExecOptions): Operation { - if (isWin32()) { - return yield* createWin32Process(command, options); - } - return yield* createPosixProcess(command, options); - }, +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); + *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, + yield* provide({ + *[Symbol.iterator]() { + let status = yield* process.join(); + throw new DaemonExitError(status, command, options); + }, + ...process, + }); }); - }); + }, }, -}); +); 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); }, From 34837639eb7b23153e1e885ed25cd001695706ae Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 15 Apr 2026 20:33:12 -0400 Subject: [PATCH 10/23] Use plain @effectionx/process scope for Stdio api --- process/src/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/process/src/api.ts b/process/src/api.ts index 5d440869..514e9be7 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("@effectionx/process:io", { +export const Stdio = createApi("@effectionx/process", { *stdout(line) { process.stdout.write(line); }, From 5d286cc5c39e3ec5b928a5f66349f67353fa0592 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 15 Apr 2026 20:34:58 -0400 Subject: [PATCH 11/23] Rename Stdio api scope to @effectionx/stdio --- process/src/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/process/src/api.ts b/process/src/api.ts index 514e9be7..96a7068d 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("@effectionx/process", { +export const Stdio = createApi("@effectionx/stdio", { *stdout(line) { process.stdout.write(line); }, From d0c814628679252a5ac2b0175485a8d4e02b780f Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 15 Apr 2026 20:42:12 -0400 Subject: [PATCH 12/23] Move Stdio api from @effectionx/process to @effectionx/node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stdio wraps the host's process.stdout/stderr streams — it has nothing process-specific about it, and @effectionx/node already houses the Node host-facing adapters. Moving it makes the package boundary real instead of cosmetic and eliminates the need to pick a distinct scope name for Stdio vs ProcessApi inside the same package. - Stdio + StdioApi now live in node/stdio.ts, exposed under the "@effectionx/node/stdio" subpath (consistent with the existing ./stream and ./events subpath exports). - Scope name is "@effectionx/node/stdio". - process/src/api.ts removed (only held Stdio). - process/src/exec/types.ts re-exports StdioApi as a type-only alias so the public type surface of @effectionx/process is preserved. - @effectionx/node now depends on @effectionx/context-api. --- node/mod.ts | 1 + node/package.json | 9 +++++++ node/stdio.ts | 53 +++++++++++++++++++++++++++++++++++++++ node/tsconfig.json | 3 +++ pnpm-lock.yaml | 4 +++ process/mod.ts | 1 - process/src/api.ts | 48 ----------------------------------- process/src/exec/posix.ts | 2 +- process/src/exec/types.ts | 8 +++--- process/src/exec/win32.ts | 2 +- process/test/exec.test.ts | 2 +- 11 files changed, 76 insertions(+), 57 deletions(-) create mode 100644 node/stdio.ts delete mode 100644 process/src/api.ts diff --git a/node/mod.ts b/node/mod.ts index 54cd6933..10f74bf2 100644 --- a/node/mod.ts +++ b/node/mod.ts @@ -1,2 +1,3 @@ export * from "./stream.ts"; export * from "./events.ts"; +export * from "./stdio.ts"; diff --git a/node/package.json b/node/package.json index 29ea8563..b3807600 100644 --- a/node/package.json +++ b/node/package.json @@ -24,11 +24,20 @@ "development": "./events.ts", "import": "./dist/events.js", "default": "./dist/events.js" + }, + "./stdio": { + "types": "./dist/stdio.d.ts", + "development": "./stdio.ts", + "import": "./dist/stdio.js", + "default": "./dist/stdio.js" } }, "peerDependencies": { "effection": "^3 || ^4" }, + "dependencies": { + "@effectionx/context-api": "workspace:*" + }, "license": "MIT", "author": "engineering@frontside.com", "repository": { diff --git a/node/stdio.ts b/node/stdio.ts new file mode 100644 index 00000000..a8df9826 --- /dev/null +++ b/node/stdio.ts @@ -0,0 +1,53 @@ +import process from "node:process"; +import { type Api, createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; + +/** + * Middleware-capable shape for host stdio writes. + */ +export interface StdioApi { + stdout(bytes: Uint8Array): Operation; + stderr(bytes: Uint8Array): Operation; +} + +/** + * Context API used to observe or customize host-process stdio handling. + * + * By default, `stdout` and `stderr` are written directly to the host + * `process.stdout` / `process.stderr` streams. Middleware can wrap this API + * via `Stdio.around(...)` to capture, transform, or redirect the bytes. + * + * @example + * ```ts + * import { main } from "effection"; + * import { Stdio } from "@effectionx/node/stdio"; + * + * await main(function* () { + * let captured: Uint8Array[] = []; + * + * yield* Stdio.around({ + * *stdout(line, next) { + * const [bytes] = line; + * captured.push(bytes); + * return yield* next(line); + * }, + * }); + * + * // Any code in this scope (including nested child-process helpers + * // that write through `Stdio.operations.stdout`) now flows through + * // the middleware instead of the host stdout stream. + * yield* Stdio.operations.stdout(new TextEncoder().encode("hello\n")); + * }); + * ``` + */ +export const Stdio: Api = createApi( + "@effectionx/node/stdio", + { + *stdout(bytes) { + process.stdout.write(bytes); + }, + *stderr(bytes) { + process.stderr.write(bytes); + }, + }, +); diff --git a/node/tsconfig.json b/node/tsconfig.json index 49b10377..6f87011f 100644 --- a/node/tsconfig.json +++ b/node/tsconfig.json @@ -7,6 +7,9 @@ "include": ["**/*.ts"], "exclude": ["**/*.test.ts", "dist"], "references": [ + { + "path": "../context-api" + }, { "path": "../vitest" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9cfc5bf..4d16af85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,10 @@ importers: version: link:../vitest node: + dependencies: + '@effectionx/context-api': + specifier: workspace:* + version: link:../context-api devDependencies: '@effectionx/vitest': specifier: workspace:* diff --git a/process/mod.ts b/process/mod.ts index 0e2f112f..91a59582 100644 --- a/process/mod.ts +++ b/process/mod.ts @@ -1,4 +1,3 @@ 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 deleted file mode 100644 index 96a7068d..00000000 --- a/process/src/api.ts +++ /dev/null @@ -1,48 +0,0 @@ -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("@effectionx/stdio", { - *stdout(line) { - process.stdout.write(line); - }, - *stderr(line) { - process.stderr.write(line); - }, -}); diff --git a/process/src/exec/posix.ts b/process/src/exec/posix.ts index 6b5bb9bc..be361db2 100644 --- a/process/src/exec/posix.ts +++ b/process/src/exec/posix.ts @@ -22,7 +22,7 @@ import type { Process, Writable, } from "./types.ts"; -import { Stdio } from "../api.ts"; +import { Stdio } from "@effectionx/node/stdio"; import { ExecError } from "./error.ts"; type ProcessResultValue = [number?, string?]; diff --git a/process/src/exec/types.ts b/process/src/exec/types.ts index a45e75d4..098f9a1e 100644 --- a/process/src/exec/types.ts +++ b/process/src/exec/types.ts @@ -1,6 +1,9 @@ import type { Operation } from "effection"; import type { OutputStream } from "../helpers.ts"; import type { Api } from "@effectionx/context-api"; +import type { StdioApi } from "@effectionx/node/stdio"; + +export type { StdioApi }; /** * Writable handle used for process stdin. @@ -102,11 +105,6 @@ export interface StdIO { stdin: Writable; } -export interface StdioApi { - stdout(bytes: Uint8Array): Operation; - stderr(bytes: Uint8Array): Operation; -} - export interface ExitStatus { /** * exit code diff --git a/process/src/exec/win32.ts b/process/src/exec/win32.ts index 3a2c78b2..666d59aa 100644 --- a/process/src/exec/win32.ts +++ b/process/src/exec/win32.ts @@ -23,7 +23,7 @@ import type { Process, Writable, } from "./types.ts"; -import { Stdio } from "../api.ts"; +import { Stdio } from "@effectionx/node/stdio"; import { ExecError } from "./error.ts"; import { unbox, useEvalScope } from "@effectionx/scope-eval"; diff --git a/process/test/exec.test.ts b/process/test/exec.test.ts index 3b6908fa..81e22eaa 100644 --- a/process/test/exec.test.ts +++ b/process/test/exec.test.ts @@ -7,7 +7,7 @@ import { captureError, expectMatch, fetchText } from "./helpers.ts"; import { lines } from "@effectionx/stream-helpers"; import { type Process, type ProcessResult, exec } from "../mod.ts"; -import { Stdio } from "../src/api.ts"; +import { Stdio } from "@effectionx/node/stdio"; const SystemRoot = process.env.SystemRoot; From 99561a7c27ada44a2626171cd0e61fed92a29366 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 15 Apr 2026 20:51:33 -0400 Subject: [PATCH 13/23] Add stdin to Stdio api as a readable Stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stdio interface was missing stdin — it only had stdout/stderr, which over-promised the name. Add stdin as `() => Operation>`, defaulting to `fromReadable(process.stdin)`. This closes the naming gap and gives middleware authors a seam to redirect or mock host stdin. --- node/stdio.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/node/stdio.ts b/node/stdio.ts index a8df9826..9cf329ca 100644 --- a/node/stdio.ts +++ b/node/stdio.ts @@ -1,11 +1,17 @@ import process from "node:process"; import { type Api, createApi } from "@effectionx/context-api"; -import type { Operation } from "effection"; +import type { Operation, Stream } from "effection"; +import { fromReadable } from "./stream.ts"; /** - * Middleware-capable shape for host stdio writes. + * Middleware-capable shape for host stdio. + * + * `stdin` yields a readable byte stream sourced from the host's standard + * input; `stdout` and `stderr` take bytes and write them to the host's + * corresponding output streams. */ export interface StdioApi { + stdin(): Operation>; stdout(bytes: Uint8Array): Operation; stderr(bytes: Uint8Array): Operation; } @@ -43,6 +49,9 @@ export interface StdioApi { export const Stdio: Api = createApi( "@effectionx/node/stdio", { + *stdin() { + return fromReadable(process.stdin); + }, *stdout(bytes) { process.stdout.write(bytes); }, From 99c212c826ab8a6113a990adefff0558282c411e Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 15 Apr 2026 21:12:40 -0400 Subject: [PATCH 14/23] Destructure Stdio.operations and use destructured names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds `export const { stdin, stdout, stderr } = Stdio.operations;` so callers can yield to the operations by their short names instead of `Stdio.operations.xxx` — mirrors the pattern FsApi uses in fs/mod.ts. - Rewrites process/src/exec/posix.ts and win32.ts to import the destructured operations. Local `createSignal` variables in those files are already named `stdout`/`stderr`, so the imports are aliased to `writeStdout`/`writeStderr` to avoid shadowing. - Adds node/stdio.test.ts with middleware tests for stdout/stderr capture and stdin substitution — the first direct tests for this api (previously only exercised transitively through the process package). - Documents the new `@effectionx/node/stdio` sub-module in node/README.md. --- node/README.md | 87 ++++++++++++++++++++++++++++++++++++++- node/stdio.test.ts | 55 +++++++++++++++++++++++++ node/stdio.ts | 2 + process/src/exec/posix.ts | 10 +++-- process/src/exec/win32.ts | 10 +++-- 5 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 node/stdio.test.ts diff --git a/node/README.md b/node/README.md index 190bc64e..317330bd 100644 --- a/node/README.md +++ b/node/README.md @@ -14,15 +14,16 @@ npm install @effectionx/node ## Modules -This package provides two sub-modules: +This package provides three sub-modules: - `@effectionx/node/stream` - Stream utilities for Node.js - `@effectionx/node/events` - Event utilities for Node.js EventEmitters +- `@effectionx/node/stdio` - Host process stdio middleware seam You can also import everything from the main module: ```typescript -import { fromReadable, on, once } from "@effectionx/node"; +import { fromReadable, on, once, Stdio, stdin, stdout, stderr } from "@effectionx/node"; ``` ## Stream Utilities @@ -103,6 +104,88 @@ await main(function* () { }); ``` +## Host Stdio + +`Stdio` is a [`createApi`][context-api] middleware seam over the host process's +`stdin`, `stdout`, and `stderr`. By default, `stdout` and `stderr` write bytes +to `process.stdout` / `process.stderr`, and `stdin` returns a readable Effection +stream sourced from `process.stdin`. Middleware can wrap these operations via +`Stdio.around(...)` to capture, transform, or substitute the bytes without +touching the call sites. + +[context-api]: ../context-api/README.md + +### stdout() + +Write bytes to the host process's standard output. + +```typescript +import { main } from "effection"; +import { stdout } from "@effectionx/node/stdio"; + +await main(function* () { + yield* stdout(new TextEncoder().encode("hello\n")); +}); +``` + +### stderr() + +Write bytes to the host process's standard error. + +```typescript +import { main } from "effection"; +import { stderr } from "@effectionx/node/stdio"; + +await main(function* () { + yield* stderr(new TextEncoder().encode("something went wrong\n")); +}); +``` + +### stdin() + +Yield an Effection `Stream` of bytes read from the host +process's standard input. + +```typescript +import { each, main } from "effection"; +import { stdin } from "@effectionx/node/stdio"; + +await main(function* () { + for (const chunk of yield* each(yield* stdin())) { + console.log(new TextDecoder().decode(chunk)); + yield* each.next(); + } +}); +``` + +### Stdio.around() + +Install middleware that intercepts any of the three operations for the current +scope. The example below captures every byte written to `stdout` into an +in-memory buffer instead of forwarding it to the host. + +```typescript +import { main } from "effection"; +import { Stdio, stdout } from "@effectionx/node/stdio"; + +await main(function* () { + const captured: Uint8Array[] = []; + + yield* Stdio.around({ + *stdout([bytes]) { + captured.push(bytes); + }, + }); + + yield* stdout(new TextEncoder().encode("hello\n")); + // captured now holds the bytes; nothing was written to process.stdout +}); +``` + +`@effectionx/process` pipes child-process output through these same operations, +so installing an `Stdio.around(...)` in an outer scope also intercepts child +process stdio from any nested `exec` calls. + ## TypeScript Support All exports include TypeScript type definitions. The event functions support diff --git a/node/stdio.test.ts b/node/stdio.test.ts new file mode 100644 index 00000000..d2e77696 --- /dev/null +++ b/node/stdio.test.ts @@ -0,0 +1,55 @@ +import { Readable } from "node:stream"; +import { describe, it } from "@effectionx/vitest"; +import { each } from "effection"; +import { expect } from "expect"; + +import { Stdio, stderr, stdin, stdout } from "./stdio.ts"; +import { fromReadable } from "./stream.ts"; + +describe("@effectionx/node/stdio", () => { + it("captures stdout through middleware", function* () { + const captured: Uint8Array[] = []; + yield* Stdio.around({ + *stdout([bytes]) { + captured.push(bytes); + }, + }); + + yield* stdout(new TextEncoder().encode("hello")); + + expect(captured).toHaveLength(1); + expect(new TextDecoder().decode(captured[0])).toBe("hello"); + }); + + it("captures stderr through middleware", function* () { + const captured: Uint8Array[] = []; + yield* Stdio.around({ + *stderr([bytes]) { + captured.push(bytes); + }, + }); + + yield* stderr(new TextEncoder().encode("oops")); + + expect(captured).toHaveLength(1); + expect(new TextDecoder().decode(captured[0])).toBe("oops"); + }); + + it("substitutes stdin through middleware", function* () { + yield* Stdio.around({ + *stdin() { + return fromReadable(Readable.from([Buffer.from("mocked")])); + }, + }); + + const stream = yield* stdin(); + const chunks: Uint8Array[] = []; + for (const chunk of yield* each(stream)) { + chunks.push(chunk); + yield* each.next(); + } + + const received = new TextDecoder().decode(Buffer.concat(chunks)); + expect(received).toBe("mocked"); + }); +}); diff --git a/node/stdio.ts b/node/stdio.ts index 9cf329ca..7e48e712 100644 --- a/node/stdio.ts +++ b/node/stdio.ts @@ -60,3 +60,5 @@ export const Stdio: Api = createApi( }, }, ); + +export const { stdin, stdout, stderr } = Stdio.operations; diff --git a/process/src/exec/posix.ts b/process/src/exec/posix.ts index be361db2..786967dd 100644 --- a/process/src/exec/posix.ts +++ b/process/src/exec/posix.ts @@ -22,7 +22,11 @@ import type { Process, Writable, } from "./types.ts"; -import { Stdio } from "@effectionx/node/stdio"; +import { + Stdio, + stderr as writeStderr, + stdout as writeStdout, +} from "@effectionx/node/stdio"; import { ExecError } from "./error.ts"; type ProcessResultValue = [number?, string?]; @@ -71,7 +75,7 @@ export function* createPosixProcess( yield* spawn(function* () { let next = yield* io.stdout.next(); while (!next.done) { - yield* Stdio.operations.stdout(next.value); + yield* writeStdout(next.value); stdout.send(next.value); next = yield* io.stdout.next(); } @@ -82,7 +86,7 @@ export function* createPosixProcess( yield* spawn(function* () { let next = yield* io.stderr.next(); while (!next.done) { - yield* Stdio.operations.stderr(next.value); + yield* writeStderr(next.value); stderr.send(next.value); next = yield* io.stderr.next(); } diff --git a/process/src/exec/win32.ts b/process/src/exec/win32.ts index 666d59aa..c37546ba 100644 --- a/process/src/exec/win32.ts +++ b/process/src/exec/win32.ts @@ -23,7 +23,11 @@ import type { Process, Writable, } from "./types.ts"; -import { Stdio } from "@effectionx/node/stdio"; +import { + Stdio, + stderr as writeStderr, + stdout as writeStdout, +} from "@effectionx/node/stdio"; import { ExecError } from "./error.ts"; import { unbox, useEvalScope } from "@effectionx/scope-eval"; @@ -89,7 +93,7 @@ export function* createWin32Process( yield* spawn(function* () { let next = yield* io.stdout.next(); while (!next.done) { - yield* Stdio.operations.stdout(next.value); + yield* writeStdout(next.value); stdout.send(next.value); next = yield* io.stdout.next(); } @@ -100,7 +104,7 @@ export function* createWin32Process( yield* spawn(function* () { let next = yield* io.stderr.next(); while (!next.done) { - yield* Stdio.operations.stderr(next.value); + yield* writeStderr(next.value); stderr.send(next.value); next = yield* io.stderr.next(); } From e1f5d2ecc7db58c5a7de3b313e28cdb7d94b7903 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 15 Apr 2026 21:21:35 -0400 Subject: [PATCH 15/23] Fix stdin to return Stream directly instead of Operation Stream is already Operation>, so wrapping it in another Operation was a needless double-indirection. The handler becomes a plain function (not a generator) since it just returns the Stream from fromReadable. --- node/stdio.test.ts | 5 ++--- node/stdio.ts | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/node/stdio.test.ts b/node/stdio.test.ts index d2e77696..0acff745 100644 --- a/node/stdio.test.ts +++ b/node/stdio.test.ts @@ -37,14 +37,13 @@ describe("@effectionx/node/stdio", () => { it("substitutes stdin through middleware", function* () { yield* Stdio.around({ - *stdin() { + stdin() { return fromReadable(Readable.from([Buffer.from("mocked")])); }, }); - const stream = yield* stdin(); const chunks: Uint8Array[] = []; - for (const chunk of yield* each(stream)) { + for (const chunk of yield* each(stdin())) { chunks.push(chunk); yield* each.next(); } diff --git a/node/stdio.ts b/node/stdio.ts index 7e48e712..17a50cec 100644 --- a/node/stdio.ts +++ b/node/stdio.ts @@ -11,7 +11,7 @@ import { fromReadable } from "./stream.ts"; * corresponding output streams. */ export interface StdioApi { - stdin(): Operation>; + stdin(): Stream; stdout(bytes: Uint8Array): Operation; stderr(bytes: Uint8Array): Operation; } @@ -49,7 +49,7 @@ export interface StdioApi { export const Stdio: Api = createApi( "@effectionx/node/stdio", { - *stdin() { + stdin() { return fromReadable(process.stdin); }, *stdout(bytes) { From 861ff29aabd730cf3b809cba553816270c205cf4 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 15 Apr 2026 21:22:58 -0400 Subject: [PATCH 16/23] Fix docs to use destructured names and remove double yield on stdin --- node/README.md | 2 +- node/stdio.ts | 12 ++++-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/node/README.md b/node/README.md index 317330bd..a0ae15a0 100644 --- a/node/README.md +++ b/node/README.md @@ -151,7 +151,7 @@ import { each, main } from "effection"; import { stdin } from "@effectionx/node/stdio"; await main(function* () { - for (const chunk of yield* each(yield* stdin())) { + for (const chunk of yield* each(stdin())) { console.log(new TextDecoder().decode(chunk)); yield* each.next(); } diff --git a/node/stdio.ts b/node/stdio.ts index 17a50cec..e4709122 100644 --- a/node/stdio.ts +++ b/node/stdio.ts @@ -26,23 +26,19 @@ export interface StdioApi { * @example * ```ts * import { main } from "effection"; - * import { Stdio } from "@effectionx/node/stdio"; + * import { Stdio, stdout } from "@effectionx/node/stdio"; * * await main(function* () { * let captured: Uint8Array[] = []; * * yield* Stdio.around({ - * *stdout(line, next) { - * const [bytes] = line; + * *stdout([bytes]) { * captured.push(bytes); - * return yield* next(line); * }, * }); * - * // Any code in this scope (including nested child-process helpers - * // that write through `Stdio.operations.stdout`) now flows through - * // the middleware instead of the host stdout stream. - * yield* Stdio.operations.stdout(new TextEncoder().encode("hello\n")); + * yield* stdout(new TextEncoder().encode("hello\n")); + * // captured now holds the bytes; nothing was written to process.stdout * }); * ``` */ From 0756bfca76ccc272fd05f04c5f1b0227cbceb056 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 17 Apr 2026 15:49:31 -0400 Subject: [PATCH 17/23] Revert "Fix docs to use destructured names and remove double yield on stdin" This reverts commit 861ff29aabd730cf3b809cba553816270c205cf4. --- node/README.md | 2 +- node/stdio.ts | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/node/README.md b/node/README.md index a0ae15a0..317330bd 100644 --- a/node/README.md +++ b/node/README.md @@ -151,7 +151,7 @@ import { each, main } from "effection"; import { stdin } from "@effectionx/node/stdio"; await main(function* () { - for (const chunk of yield* each(stdin())) { + for (const chunk of yield* each(yield* stdin())) { console.log(new TextDecoder().decode(chunk)); yield* each.next(); } diff --git a/node/stdio.ts b/node/stdio.ts index e4709122..17a50cec 100644 --- a/node/stdio.ts +++ b/node/stdio.ts @@ -26,19 +26,23 @@ export interface StdioApi { * @example * ```ts * import { main } from "effection"; - * import { Stdio, stdout } from "@effectionx/node/stdio"; + * import { Stdio } from "@effectionx/node/stdio"; * * await main(function* () { * let captured: Uint8Array[] = []; * * yield* Stdio.around({ - * *stdout([bytes]) { + * *stdout(line, next) { + * const [bytes] = line; * captured.push(bytes); + * return yield* next(line); * }, * }); * - * yield* stdout(new TextEncoder().encode("hello\n")); - * // captured now holds the bytes; nothing was written to process.stdout + * // Any code in this scope (including nested child-process helpers + * // that write through `Stdio.operations.stdout`) now flows through + * // the middleware instead of the host stdout stream. + * yield* Stdio.operations.stdout(new TextEncoder().encode("hello\n")); * }); * ``` */ From bfa7d9ddea3d5cb8992c8f0c5fdeed8863568b15 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 17 Apr 2026 15:49:35 -0400 Subject: [PATCH 18/23] Revert "Fix stdin to return Stream directly instead of Operation" This reverts commit e1f5d2ecc7db58c5a7de3b313e28cdb7d94b7903. --- node/stdio.test.ts | 5 +++-- node/stdio.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/node/stdio.test.ts b/node/stdio.test.ts index 0acff745..d2e77696 100644 --- a/node/stdio.test.ts +++ b/node/stdio.test.ts @@ -37,13 +37,14 @@ describe("@effectionx/node/stdio", () => { it("substitutes stdin through middleware", function* () { yield* Stdio.around({ - stdin() { + *stdin() { return fromReadable(Readable.from([Buffer.from("mocked")])); }, }); + const stream = yield* stdin(); const chunks: Uint8Array[] = []; - for (const chunk of yield* each(stdin())) { + for (const chunk of yield* each(stream)) { chunks.push(chunk); yield* each.next(); } diff --git a/node/stdio.ts b/node/stdio.ts index 17a50cec..7e48e712 100644 --- a/node/stdio.ts +++ b/node/stdio.ts @@ -11,7 +11,7 @@ import { fromReadable } from "./stream.ts"; * corresponding output streams. */ export interface StdioApi { - stdin(): Stream; + stdin(): Operation>; stdout(bytes: Uint8Array): Operation; stderr(bytes: Uint8Array): Operation; } @@ -49,7 +49,7 @@ export interface StdioApi { export const Stdio: Api = createApi( "@effectionx/node/stdio", { - stdin() { + *stdin() { return fromReadable(process.stdin); }, *stdout(bytes) { From e88a23b403aefebaf6f64f79407db30645db17c8 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 17 Apr 2026 15:49:36 -0400 Subject: [PATCH 19/23] Revert "Destructure Stdio.operations and use destructured names" This reverts commit 99c212c826ab8a6113a990adefff0558282c411e. --- node/README.md | 87 +-------------------------------------- node/stdio.test.ts | 55 ------------------------- node/stdio.ts | 2 - process/src/exec/posix.ts | 10 ++--- process/src/exec/win32.ts | 10 ++--- 5 files changed, 8 insertions(+), 156 deletions(-) delete mode 100644 node/stdio.test.ts diff --git a/node/README.md b/node/README.md index 317330bd..190bc64e 100644 --- a/node/README.md +++ b/node/README.md @@ -14,16 +14,15 @@ npm install @effectionx/node ## Modules -This package provides three sub-modules: +This package provides two sub-modules: - `@effectionx/node/stream` - Stream utilities for Node.js - `@effectionx/node/events` - Event utilities for Node.js EventEmitters -- `@effectionx/node/stdio` - Host process stdio middleware seam You can also import everything from the main module: ```typescript -import { fromReadable, on, once, Stdio, stdin, stdout, stderr } from "@effectionx/node"; +import { fromReadable, on, once } from "@effectionx/node"; ``` ## Stream Utilities @@ -104,88 +103,6 @@ await main(function* () { }); ``` -## Host Stdio - -`Stdio` is a [`createApi`][context-api] middleware seam over the host process's -`stdin`, `stdout`, and `stderr`. By default, `stdout` and `stderr` write bytes -to `process.stdout` / `process.stderr`, and `stdin` returns a readable Effection -stream sourced from `process.stdin`. Middleware can wrap these operations via -`Stdio.around(...)` to capture, transform, or substitute the bytes without -touching the call sites. - -[context-api]: ../context-api/README.md - -### stdout() - -Write bytes to the host process's standard output. - -```typescript -import { main } from "effection"; -import { stdout } from "@effectionx/node/stdio"; - -await main(function* () { - yield* stdout(new TextEncoder().encode("hello\n")); -}); -``` - -### stderr() - -Write bytes to the host process's standard error. - -```typescript -import { main } from "effection"; -import { stderr } from "@effectionx/node/stdio"; - -await main(function* () { - yield* stderr(new TextEncoder().encode("something went wrong\n")); -}); -``` - -### stdin() - -Yield an Effection `Stream` of bytes read from the host -process's standard input. - -```typescript -import { each, main } from "effection"; -import { stdin } from "@effectionx/node/stdio"; - -await main(function* () { - for (const chunk of yield* each(yield* stdin())) { - console.log(new TextDecoder().decode(chunk)); - yield* each.next(); - } -}); -``` - -### Stdio.around() - -Install middleware that intercepts any of the three operations for the current -scope. The example below captures every byte written to `stdout` into an -in-memory buffer instead of forwarding it to the host. - -```typescript -import { main } from "effection"; -import { Stdio, stdout } from "@effectionx/node/stdio"; - -await main(function* () { - const captured: Uint8Array[] = []; - - yield* Stdio.around({ - *stdout([bytes]) { - captured.push(bytes); - }, - }); - - yield* stdout(new TextEncoder().encode("hello\n")); - // captured now holds the bytes; nothing was written to process.stdout -}); -``` - -`@effectionx/process` pipes child-process output through these same operations, -so installing an `Stdio.around(...)` in an outer scope also intercepts child -process stdio from any nested `exec` calls. - ## TypeScript Support All exports include TypeScript type definitions. The event functions support diff --git a/node/stdio.test.ts b/node/stdio.test.ts deleted file mode 100644 index d2e77696..00000000 --- a/node/stdio.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Readable } from "node:stream"; -import { describe, it } from "@effectionx/vitest"; -import { each } from "effection"; -import { expect } from "expect"; - -import { Stdio, stderr, stdin, stdout } from "./stdio.ts"; -import { fromReadable } from "./stream.ts"; - -describe("@effectionx/node/stdio", () => { - it("captures stdout through middleware", function* () { - const captured: Uint8Array[] = []; - yield* Stdio.around({ - *stdout([bytes]) { - captured.push(bytes); - }, - }); - - yield* stdout(new TextEncoder().encode("hello")); - - expect(captured).toHaveLength(1); - expect(new TextDecoder().decode(captured[0])).toBe("hello"); - }); - - it("captures stderr through middleware", function* () { - const captured: Uint8Array[] = []; - yield* Stdio.around({ - *stderr([bytes]) { - captured.push(bytes); - }, - }); - - yield* stderr(new TextEncoder().encode("oops")); - - expect(captured).toHaveLength(1); - expect(new TextDecoder().decode(captured[0])).toBe("oops"); - }); - - it("substitutes stdin through middleware", function* () { - yield* Stdio.around({ - *stdin() { - return fromReadable(Readable.from([Buffer.from("mocked")])); - }, - }); - - const stream = yield* stdin(); - const chunks: Uint8Array[] = []; - for (const chunk of yield* each(stream)) { - chunks.push(chunk); - yield* each.next(); - } - - const received = new TextDecoder().decode(Buffer.concat(chunks)); - expect(received).toBe("mocked"); - }); -}); diff --git a/node/stdio.ts b/node/stdio.ts index 7e48e712..9cf329ca 100644 --- a/node/stdio.ts +++ b/node/stdio.ts @@ -60,5 +60,3 @@ export const Stdio: Api = createApi( }, }, ); - -export const { stdin, stdout, stderr } = Stdio.operations; diff --git a/process/src/exec/posix.ts b/process/src/exec/posix.ts index 786967dd..be361db2 100644 --- a/process/src/exec/posix.ts +++ b/process/src/exec/posix.ts @@ -22,11 +22,7 @@ import type { Process, Writable, } from "./types.ts"; -import { - Stdio, - stderr as writeStderr, - stdout as writeStdout, -} from "@effectionx/node/stdio"; +import { Stdio } from "@effectionx/node/stdio"; import { ExecError } from "./error.ts"; type ProcessResultValue = [number?, string?]; @@ -75,7 +71,7 @@ export function* createPosixProcess( yield* spawn(function* () { let next = yield* io.stdout.next(); while (!next.done) { - yield* writeStdout(next.value); + yield* Stdio.operations.stdout(next.value); stdout.send(next.value); next = yield* io.stdout.next(); } @@ -86,7 +82,7 @@ export function* createPosixProcess( yield* spawn(function* () { let next = yield* io.stderr.next(); while (!next.done) { - yield* writeStderr(next.value); + yield* Stdio.operations.stderr(next.value); stderr.send(next.value); next = yield* io.stderr.next(); } diff --git a/process/src/exec/win32.ts b/process/src/exec/win32.ts index c37546ba..666d59aa 100644 --- a/process/src/exec/win32.ts +++ b/process/src/exec/win32.ts @@ -23,11 +23,7 @@ import type { Process, Writable, } from "./types.ts"; -import { - Stdio, - stderr as writeStderr, - stdout as writeStdout, -} from "@effectionx/node/stdio"; +import { Stdio } from "@effectionx/node/stdio"; import { ExecError } from "./error.ts"; import { unbox, useEvalScope } from "@effectionx/scope-eval"; @@ -93,7 +89,7 @@ export function* createWin32Process( yield* spawn(function* () { let next = yield* io.stdout.next(); while (!next.done) { - yield* writeStdout(next.value); + yield* Stdio.operations.stdout(next.value); stdout.send(next.value); next = yield* io.stdout.next(); } @@ -104,7 +100,7 @@ export function* createWin32Process( yield* spawn(function* () { let next = yield* io.stderr.next(); while (!next.done) { - yield* writeStderr(next.value); + yield* Stdio.operations.stderr(next.value); stderr.send(next.value); next = yield* io.stderr.next(); } From 44b94e32a34f3101a4c2204e913d176ca684257a Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 17 Apr 2026 15:49:36 -0400 Subject: [PATCH 20/23] Revert "Add stdin to Stdio api as a readable Stream" This reverts commit 99561a7c27ada44a2626171cd0e61fed92a29366. --- node/stdio.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/node/stdio.ts b/node/stdio.ts index 9cf329ca..a8df9826 100644 --- a/node/stdio.ts +++ b/node/stdio.ts @@ -1,17 +1,11 @@ import process from "node:process"; import { type Api, createApi } from "@effectionx/context-api"; -import type { Operation, Stream } from "effection"; -import { fromReadable } from "./stream.ts"; +import type { Operation } from "effection"; /** - * Middleware-capable shape for host stdio. - * - * `stdin` yields a readable byte stream sourced from the host's standard - * input; `stdout` and `stderr` take bytes and write them to the host's - * corresponding output streams. + * Middleware-capable shape for host stdio writes. */ export interface StdioApi { - stdin(): Operation>; stdout(bytes: Uint8Array): Operation; stderr(bytes: Uint8Array): Operation; } @@ -49,9 +43,6 @@ export interface StdioApi { export const Stdio: Api = createApi( "@effectionx/node/stdio", { - *stdin() { - return fromReadable(process.stdin); - }, *stdout(bytes) { process.stdout.write(bytes); }, From 3ff682d0019151e475d8cc583584e0fb0b8d365e Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 17 Apr 2026 15:49:42 -0400 Subject: [PATCH 21/23] Revert "Move Stdio api from @effectionx/process to @effectionx/node" This reverts commit d0c814628679252a5ac2b0175485a8d4e02b780f. --- node/mod.ts | 1 - node/package.json | 9 ------- node/stdio.ts | 53 --------------------------------------- node/tsconfig.json | 3 --- pnpm-lock.yaml | 4 --- process/mod.ts | 1 + process/src/api.ts | 48 +++++++++++++++++++++++++++++++++++ process/src/exec/posix.ts | 2 +- process/src/exec/types.ts | 8 +++--- process/src/exec/win32.ts | 2 +- process/test/exec.test.ts | 2 +- 11 files changed, 57 insertions(+), 76 deletions(-) delete mode 100644 node/stdio.ts create mode 100644 process/src/api.ts diff --git a/node/mod.ts b/node/mod.ts index 10f74bf2..54cd6933 100644 --- a/node/mod.ts +++ b/node/mod.ts @@ -1,3 +1,2 @@ export * from "./stream.ts"; export * from "./events.ts"; -export * from "./stdio.ts"; diff --git a/node/package.json b/node/package.json index b3807600..29ea8563 100644 --- a/node/package.json +++ b/node/package.json @@ -24,20 +24,11 @@ "development": "./events.ts", "import": "./dist/events.js", "default": "./dist/events.js" - }, - "./stdio": { - "types": "./dist/stdio.d.ts", - "development": "./stdio.ts", - "import": "./dist/stdio.js", - "default": "./dist/stdio.js" } }, "peerDependencies": { "effection": "^3 || ^4" }, - "dependencies": { - "@effectionx/context-api": "workspace:*" - }, "license": "MIT", "author": "engineering@frontside.com", "repository": { diff --git a/node/stdio.ts b/node/stdio.ts deleted file mode 100644 index a8df9826..00000000 --- a/node/stdio.ts +++ /dev/null @@ -1,53 +0,0 @@ -import process from "node:process"; -import { type Api, createApi } from "@effectionx/context-api"; -import type { Operation } from "effection"; - -/** - * Middleware-capable shape for host stdio writes. - */ -export interface StdioApi { - stdout(bytes: Uint8Array): Operation; - stderr(bytes: Uint8Array): Operation; -} - -/** - * Context API used to observe or customize host-process stdio handling. - * - * By default, `stdout` and `stderr` are written directly to the host - * `process.stdout` / `process.stderr` streams. Middleware can wrap this API - * via `Stdio.around(...)` to capture, transform, or redirect the bytes. - * - * @example - * ```ts - * import { main } from "effection"; - * import { Stdio } from "@effectionx/node/stdio"; - * - * await main(function* () { - * let captured: Uint8Array[] = []; - * - * yield* Stdio.around({ - * *stdout(line, next) { - * const [bytes] = line; - * captured.push(bytes); - * return yield* next(line); - * }, - * }); - * - * // Any code in this scope (including nested child-process helpers - * // that write through `Stdio.operations.stdout`) now flows through - * // the middleware instead of the host stdout stream. - * yield* Stdio.operations.stdout(new TextEncoder().encode("hello\n")); - * }); - * ``` - */ -export const Stdio: Api = createApi( - "@effectionx/node/stdio", - { - *stdout(bytes) { - process.stdout.write(bytes); - }, - *stderr(bytes) { - process.stderr.write(bytes); - }, - }, -); diff --git a/node/tsconfig.json b/node/tsconfig.json index 6f87011f..49b10377 100644 --- a/node/tsconfig.json +++ b/node/tsconfig.json @@ -7,9 +7,6 @@ "include": ["**/*.ts"], "exclude": ["**/*.test.ts", "dist"], "references": [ - { - "path": "../context-api" - }, { "path": "../vitest" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d16af85..e9cfc5bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,10 +166,6 @@ importers: version: link:../vitest node: - dependencies: - '@effectionx/context-api': - specifier: workspace:* - version: link:../context-api devDependencies: '@effectionx/vitest': specifier: workspace:* diff --git a/process/mod.ts b/process/mod.ts index 91a59582..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 new file mode 100644 index 00000000..96a7068d --- /dev/null +++ b/process/src/api.ts @@ -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("@effectionx/stdio", { + *stdout(line) { + process.stdout.write(line); + }, + *stderr(line) { + process.stderr.write(line); + }, +}); diff --git a/process/src/exec/posix.ts b/process/src/exec/posix.ts index be361db2..6b5bb9bc 100644 --- a/process/src/exec/posix.ts +++ b/process/src/exec/posix.ts @@ -22,7 +22,7 @@ import type { Process, Writable, } from "./types.ts"; -import { Stdio } from "@effectionx/node/stdio"; +import { Stdio } from "../api.ts"; import { ExecError } from "./error.ts"; type ProcessResultValue = [number?, string?]; diff --git a/process/src/exec/types.ts b/process/src/exec/types.ts index 098f9a1e..a45e75d4 100644 --- a/process/src/exec/types.ts +++ b/process/src/exec/types.ts @@ -1,9 +1,6 @@ import type { Operation } from "effection"; import type { OutputStream } from "../helpers.ts"; import type { Api } from "@effectionx/context-api"; -import type { StdioApi } from "@effectionx/node/stdio"; - -export type { StdioApi }; /** * Writable handle used for process stdin. @@ -105,6 +102,11 @@ export interface StdIO { stdin: Writable; } +export interface StdioApi { + stdout(bytes: Uint8Array): Operation; + stderr(bytes: Uint8Array): Operation; +} + export interface ExitStatus { /** * exit code diff --git a/process/src/exec/win32.ts b/process/src/exec/win32.ts index 666d59aa..3a2c78b2 100644 --- a/process/src/exec/win32.ts +++ b/process/src/exec/win32.ts @@ -23,7 +23,7 @@ import type { Process, Writable, } from "./types.ts"; -import { Stdio } from "@effectionx/node/stdio"; +import { Stdio } from "../api.ts"; import { ExecError } from "./error.ts"; import { unbox, useEvalScope } from "@effectionx/scope-eval"; diff --git a/process/test/exec.test.ts b/process/test/exec.test.ts index 81e22eaa..3b6908fa 100644 --- a/process/test/exec.test.ts +++ b/process/test/exec.test.ts @@ -7,7 +7,7 @@ import { captureError, expectMatch, fetchText } from "./helpers.ts"; import { lines } from "@effectionx/stream-helpers"; import { type Process, type ProcessResult, exec } from "../mod.ts"; -import { Stdio } from "@effectionx/node/stdio"; +import { Stdio } from "../src/api.ts"; const SystemRoot = process.env.SystemRoot; From 06dfc3d749e372aa2cc8844dbdf42b6024bf4bc3 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 17 Apr 2026 16:20:06 -0400 Subject: [PATCH 22/23] Restore process:io scope for Stdio api --- process/src/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/process/src/api.ts b/process/src/api.ts index 96a7068d..b6a546bf 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("@effectionx/stdio", { +export const Stdio = createApi("process:io", { *stdout(line) { process.stdout.write(line); }, From 8b407506905c6203ea8378a6126c16d1aa8af5ee Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 17 Apr 2026 16:20:35 -0400 Subject: [PATCH 23/23] Use @effectionx/process:io scope for Stdio api --- process/src/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); },